diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 48c8f6d16..7106716cb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -34,7 +34,18 @@ "WebFetch(domain:core.telegram.org)", "Bash(go doc:*)", "Bash(go install:*)", - "WebFetch(domain:raw.githubusercontent.com)" + "WebFetch(domain:raw.githubusercontent.com)", + "Bash(bash /tmp/measure_conflicts.sh 2>&1 | grep -E '\\(Conflict|Top 10|^\\\\s+\\\\d\\)')", + "Bash(bash /tmp/measure_conflicts.sh 2>&1 | tail -12)", + "Bash(bash /tmp/measure_conflicts.sh 2>&1 | grep '^\\\\s' | sort -rn)", + "Bash(bash /tmp/measure_conflicts.sh 2>&1)", + "Bash(bash /tmp/measure_conflicts.sh upstream/main 2>&1)", + "Bash(while read:*)", + "Bash(do echo:*)", + "Bash(done)", + "Bash(export PATH=$PATH:/home/exe/go/bin && echo \"=== loop.go ===\" && gocyclo -top 15 pkg/agent/loop.go && echo \"\" && echo \"=== loop_hooks.go ===\" && gocyclo -top 10 pkg/agent/loop_hooks.go)", + "Bash(git merge:*)", + "Bash(git checkout:*k)" ] }, "remote": { diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..0103fcff1 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,138 @@ +name: Nightly Build + +on: + schedule: + - cron: '0 0 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + nightly: + name: Nightly Build + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Compute version + id: version + run: | + DATE=$(date -u +%Y%m%d) + SHA=$(git rev-parse --short=8 HEAD) + BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true) + if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then + VERSION="v0.0.0-nightly.${DATE}.${SHA}" + else + VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}" + fi + + COMPARE_URL="https://github.com/${{ github.repository }}/commits/main" + if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then + COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main" + fi + + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT" + + - name: Setup Go from go.mod + id: setup-go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create local tag for GoReleaser + run: git tag "${{ steps.version.outputs.version }}" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: ~> v2 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} + DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} + GOVERSION: ${{ steps.setup-go.outputs.go-version }} + GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }} + NIGHTLY_BUILD: "true" + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} + + - name: Update nightly release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + run: | + CHANGELOG='${{ steps.version.outputs.changelog }}' + NOTES=$(cat </dev/null || true + + # Force-update nightly tag to current HEAD + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -fa nightly -m "Nightly build ${VERSION}" + git push origin nightly + + # Collect release artifacts from goreleaser dist/ + ASSETS=() + for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do + [ -f "$f" ] && ASSETS+=("$f") + done + + # Create nightly release (prerelease, NOT latest) + gh release create nightly \ + --title "Nightly Build" \ + --notes "$NOTES" \ + --target "${{ github.sha }}" \ + --prerelease \ + --latest=false \ + "${ASSETS[@]}" + diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a839715c9..d69928d82 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,7 +1,7 @@ name: PR on: - pull_request: {} + pull_request: { } jobs: lint: @@ -19,6 +19,9 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 + - name: Install frontend deps + run: cd pkg/miniapp/frontend && bun install + - name: Run go generate run: go generate ./... @@ -61,23 +64,11 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 - - name: Setup Node - uses: actions/setup-node@v6 - with: - node-version: '24' - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 + - name: Install frontend deps + run: cd pkg/miniapp/frontend && bun install - name: Run go generate run: go generate ./... - - name: Run frontend tests - run: | - pnpm --dir pkg/miniapp/frontend install --frozen-lockfile - pnpm --dir pkg/miniapp/frontend test - - name: Run go test run: go test ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0edd29f22..4a584773d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -65,6 +65,14 @@ jobs: with: go-version-file: go.mod + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup pnpm + run: corepack enable && corepack prepare pnpm@latest --activate + - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -96,6 +104,11 @@ jobs: GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} GOVERSION: ${{ steps.setup-go.outputs.go-version }} + MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }} + MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }} + MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }} + MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }} + MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }} - name: Apply release flags shell: bash diff --git a/.gitignore b/.gitignore index d9fe0d5a9..61fe494ca 100644 --- a/.gitignore +++ b/.gitignore @@ -47,14 +47,12 @@ docs/plans/ # Added by goreleaser init: dist/ -!pkg/miniapp/static/dist/ -!pkg/miniapp/static/dist/** +*.vite/ # Windows Application Icon/Resource *.syso - -# Frontend dependencies -node_modules/ - - +# Keep embedded backend dist directory placeholder in VCS +!web/backend/dist/ +web/backend/dist/* +!web/backend/dist/.gitkeep diff --git a/.goreleaser.yaml b/.goreleaser.yaml index d531d106b..622cf054b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -6,8 +6,9 @@ before: hooks: - go mod tidy - go generate ./... + - sh -c 'cd web/frontend && pnpm install && pnpm build:backend' - go install github.com/tc-hib/go-winres@latest - - go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }} + - go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }} builds: - id: picoclaw @@ -17,10 +18,10 @@ builds: - stdjson ldflags: - -s -w - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }} - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }} - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }} - - -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }} + - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }} + - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }} + - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }} + - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }} goos: - linux - windows @@ -32,9 +33,13 @@ builds: - riscv64 - loong64 - arm + - s390x + - mipsle goarm: - "6" - "7" + gomips: + - softfloat main: ./cmd/picoclaw ignore: - goos: windows @@ -59,10 +64,14 @@ builds: - riscv64 - loong64 - arm + - s390x + - mipsle goarm: - "6" - "7" - main: ./cmd/picoclaw-launcher + gomips: + - softfloat + main: ./web/backend ignore: - goos: windows goarch: arm @@ -86,9 +95,13 @@ builds: - riscv64 - loong64 - arm + - s390x + - mipsle goarm: - "6" - "7" + gomips: + - softfloat main: ./cmd/picoclaw-launcher-tui ignore: - goos: windows @@ -103,15 +116,49 @@ dockers_v2: - picoclaw images: - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - - "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}" + - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' tags: - - "{{ .Tag }}" - - "latest" + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}' + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}' platforms: - linux/amd64 - linux/arm64 - linux/riscv64 + - id: picoclaw-launcher + dockerfile: docker/Dockerfile.goreleaser.launcher + ids: + - picoclaw + - picoclaw-launcher + - picoclaw-launcher-tui + images: + - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" + - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}' + tags: + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}' + - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}' + platforms: + - linux/amd64 + - linux/arm64 + - linux/riscv64 + +notarize: + macos: + - enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}' + ids: + - picoclaw + - picoclaw-launcher + - picoclaw-launcher-tui + sign: + certificate: "{{.Env.MACOS_SIGN_P12}}" + password: "{{.Env.MACOS_SIGN_PASSWORD}}" + notarize: + issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}" + key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}" + key: "{{.Env.MACOS_NOTARY_KEY}}" + wait: true + timeout: 20m + archives: - formats: [tar.gz] # this name template makes the OS and Arch compatible with the results of `uname`. @@ -129,7 +176,7 @@ archives: nfpms: - id: picoclaw - builds: + ids: - picoclaw - picoclaw-launcher - picoclaw-launcher-tui @@ -149,6 +196,11 @@ nfpms: - rpm - deb bindir: /usr/bin + contents: + - src: web/picoclaw-launcher.desktop + dst: /usr/share/applications/picoclaw-launcher.desktop + - src: web/picoclaw-launcher.png + dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png changelog: sort: asc @@ -163,6 +215,7 @@ changelog: # lzma: true release: + disable: '{{ isEnvSet "NIGHTLY_BUILD" }}' footer: >- --- diff --git a/Makefile b/Makefile index 428e386c5..98642703f 100644 --- a/Makefile +++ b/Makefile @@ -11,8 +11,8 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') -INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal -LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w" +CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config +LDFLAGS=-ldflags "-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w" # Go variables GO?=CGO_ENABLED=0 go @@ -110,12 +110,18 @@ build: generate @$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) - @if [ "$(PLATFORM)" = "linux" ] || [ "$(PLATFORM)" = "darwin" ]; then \ - echo "Install to /usr/local/bin:"; \ - echo " sudo install -m 755 $(BINARY_PATH) /usr/local/bin/$(BINARY_NAME)"; \ - else \ - echo "Install hint skipped: /usr/local/bin command is for Linux/macOS (current: $(PLATFORM))."; \ + +## build-launcher: Build the picoclaw-launcher (web console) binary +build-launcher: + @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." + @mkdir -p $(BUILD_DIR) + @if [ ! -f web/backend/dist/index.html ]; then \ + echo "Building frontend..."; \ + cd web/frontend && pnpm install && pnpm build:backend; \ fi + @$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend + @ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher + @echo "Build complete: $(BUILD_DIR)/picoclaw-launcher" ## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary build-whatsapp-native: generate diff --git a/README.fr.md b/README.fr.md index 08a1926b6..1d5a3a256 100644 --- a/README.fr.md +++ b/README.fr.md @@ -1,5 +1,5 @@
- PicoClaw + PicoClaw

PicoClaw : Assistant IA Ultra-Efficace en Go

@@ -206,9 +206,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d ### 🚀 Démarrage Rapide > [!TIP] -> Configurez votre clé API dans `~/.picoclaw/config.json`. -> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré. +> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois). **1. Initialiser** @@ -222,8 +220,13 @@ picoclaw onboard { "model_list": [ { - "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key", "request_timeout": 300, "api_base": "https://api.openai.com/v1" @@ -231,7 +234,7 @@ picoclaw onboard ], "agents": { "defaults": { - "model_name": "gpt4" + "model_name": "gpt-5.4" } }, "channels": { @@ -649,7 +652,6 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/. ├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) ├── IDENTITY.md # Identité de l'Agent ├── SOUL.md # Âme de l'Agent -├── TOOLS.md # Description des outils └── USER.md # Préférences utilisateur ``` @@ -978,8 +980,10 @@ Cette conception permet également le **support multi-agent** avec une sélectio | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) | -| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) | +| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | @@ -989,8 +993,13 @@ Cette conception permet également le **support multi-agent** avec une sélectio { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { @@ -1006,7 +1015,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio ], "agents": { "defaults": { - "model": "gpt-5.2" + "model": "gpt-5.4" } } } @@ -1017,8 +1026,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio **OpenAI** ```json { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` @@ -1061,14 +1079,14 @@ Configurez plusieurs points de terminaison pour le même nom de modèle—PicoCl { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } @@ -1200,6 +1218,13 @@ Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assu | Service | Offre Gratuite | Cas d'Utilisation | | ---------------- | -------------------- | ------------------------------------- | | **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois | +| **Volcengine CodingPlan** | 9,9¥/premier mois | Idéal pour les utilisateurs chinois, multiples modèles SOTA (Doubao, DeepSeek, etc.) | +| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois | | **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web | | **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) | + +--- + +
+ PicoClaw Meme +
diff --git a/README.ja.md b/README.ja.md index c4c5b27a0..d41975294 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

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

@@ -168,9 +168,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d ### 🚀 クイックスタート(ネイティブ) > [!TIP] -> `~/.picoclaw/config.json` に API キーを設定してください。 -> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料) +> `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。 **1. 初期化** @@ -184,8 +182,13 @@ picoclaw onboard { "model_list": [ { - "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key", "request_timeout": 300, "api_base": "https://api.openai.com/v1" @@ -193,7 +196,7 @@ picoclaw onboard ], "agents": { "defaults": { - "model_name": "gpt4" + "model_name": "gpt-5.4" } }, "channels": { @@ -610,7 +613,6 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw ├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認) ├── IDENTITY.md # エージェントのアイデンティティ ├── SOUL.md # エージェントのソウル -├── TOOLS.md # ツールの説明 └── USER.md # ユーザー設定 ``` @@ -919,8 +921,10 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) | -| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) | +| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | @@ -930,8 +934,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { @@ -947,7 +956,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る ], "agents": { "defaults": { - "model": "gpt-5.2" + "model": "gpt-5.4" } } } @@ -958,8 +967,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る **OpenAI** ```json { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` @@ -1002,14 +1020,14 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } @@ -1120,9 +1138,16 @@ Web 検索を有効にするには: | サービス | 無料枠 | ユースケース | |---------|--------|------------| | **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) | -| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 | +| **Volcengine CodingPlan** | 9.9元/初月 | 中国ユーザーに最適、複数のSOTAモデル(Doubao、DeepSeek等) | +| **Zhipu** | 月 200K トークン | 中国ユーザーに適している | | **Qwen** | 無料枠あり | 通義千問 (Qwen) | | **Brave Search** | 月 2000 クエリ | Web 検索機能 | | **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 | | **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) | | **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) | + +--- + +
+ PicoClaw Meme +
diff --git a/README.md b/README.md index 5cf9f6143..ebcd6dddf 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- PicoClaw + PicoClaw

PicoClaw: Ultra-Efficient AI Assistant in Go

@@ -194,6 +194,19 @@ docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down ``` +### Launcher Mode (Web Console) + +The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat. + +```bash +docker compose -f docker/docker-compose.yml --profile launcher up -d +``` + +Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically. + +> [!WARNING] +> The web console does not yet support authentication. Avoid exposing it to the public internet. + ### Agent Mode (One-shot) ```bash @@ -214,9 +227,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d ### 🚀 Quick Start > [!TIP] -> Set your API key in `~/.picoclaw/config.json`. -> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback. +> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month). **1. Initialize** @@ -231,7 +242,7 @@ picoclaw onboard "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model_name": "gpt4", + "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -239,8 +250,13 @@ picoclaw onboard }, "model_list": [ { - "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "your-api-key", "request_timeout": 300 }, @@ -774,7 +790,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) ├── IDENTITY.md # Agent identity ├── SOUL.md # Agent soul -├── TOOLS.md # Tool descriptions └── USER.md # User preferences ``` @@ -1018,9 +1033,11 @@ This design also enables **multi-agent support** with flexible provider selectio | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | -| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | +| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | @@ -1030,8 +1047,13 @@ This design also enables **multi-agent support** with flexible provider selectio { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { @@ -1047,7 +1069,7 @@ This design also enables **multi-agent support** with flexible provider selectio ], "agents": { "defaults": { - "model": "gpt-5.2" + "model": "gpt-5.4" } } } @@ -1059,8 +1081,18 @@ This design also enables **multi-agent support** with flexible provider selectio ```json { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` @@ -1139,14 +1171,14 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } @@ -1486,8 +1518,16 @@ This happens when another instance of the bot is running. Make sure only one `pi | Service | Free Tier | Use Case | | ---------------- | ------------------------ | ------------------------------------- | | **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/month | Best for Chinese users | +| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) | +| **Zhipu** | 200K tokens/month | Suitable for Chinese users | | **Brave Search** | Paid ($5/1000 queries) | Web search functionality | | **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | +| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) | + +--- + +
+ PicoClaw Meme +
diff --git a/README.pt-br.md b/README.pt-br.md index 5f37ba457..474cb199c 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

PicoClaw: Assistente de IA Ultra-Eficiente em Go

@@ -207,9 +207,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d ### 🚀 Início Rápido > [!TIP] -> Configure sua API key em `~/.picoclaw/config.json`. -> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado. +> Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Busca web é **opcional** — obtenha a [API Tavily](https://tavily.com) gratuita (1000 consultas grátis/mês) ou a [Brave Search API](https://brave.com/search/api) (2000 consultas grátis/mês). **1. Inicializar** @@ -223,8 +221,13 @@ picoclaw onboard { "model_list": [ { - "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key", "request_timeout": 300, "api_base": "https://api.openai.com/v1" @@ -232,7 +235,7 @@ picoclaw onboard ], "agents": { "defaults": { - "model_name": "gpt4" + "model_name": "gpt-5.4" } }, "tools": { @@ -645,7 +648,6 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp ├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) ├── IDENTITY.md # Identidade do Agente ├── SOUL.md # Alma do Agente -├── TOOLS.md # Descrição das ferramentas └── USER.md # Preferencias do usuario ``` @@ -974,8 +976,10 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) | -| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) | +| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | @@ -985,8 +989,13 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { @@ -1002,7 +1011,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve ], "agents": { "defaults": { - "model": "gpt-5.2" + "model": "gpt-5.4" } } } @@ -1013,8 +1022,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve **OpenAI** ```json { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` @@ -1057,14 +1075,14 @@ Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-r { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } @@ -1196,7 +1214,14 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se | Serviço | Plano Gratuito | Caso de Uso | | --- | --- | --- | | **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses | +| **Volcengine CodingPlan** | ¥9,9/primeiro mês | Ideal para usuários chineses, múltiplos modelos SOTA (Doubao, DeepSeek, etc.) | +| **Zhipu** | 200K tokens/mês | Adequado para usuários chineses | | **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web | | **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) | | **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) | + +--- + +
+ PicoClaw Meme +
diff --git a/README.vi.md b/README.vi.md index 92c6ecbae..38eed4d4d 100644 --- a/README.vi.md +++ b/README.vi.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

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

@@ -187,9 +187,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d ### 🚀 Bắt đầu nhanh > [!TIP] -> Thiết lập API key trong `~/.picoclaw/config.json`. -> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn. +> Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [Tavily API](https://tavily.com) miễn phí (1000 truy vấn/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn/tháng). **1. Khởi tạo** @@ -203,8 +201,13 @@ picoclaw onboard { "model_list": [ { - "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key", "request_timeout": 300, "api_base": "https://api.openai.com/v1" @@ -617,7 +620,6 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: ├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) ├── IDENTITY.md # Danh tính Agent ├── SOUL.md # Tâm hồn/Tính cách Agent -├── TOOLS.md # Mô tả công cụ └── USER.md # Tùy chọn người dùng ``` @@ -943,8 +945,10 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) | -| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) | +| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | @@ -954,8 +958,13 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { @@ -971,7 +980,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch ], "agents": { "defaults": { - "model": "gpt-5.2" + "model": "gpt-5.4" } } } @@ -982,8 +991,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch **OpenAI** ```json { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**VolcEngine (Doubao)** +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` @@ -1026,14 +1044,14 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } @@ -1165,6 +1183,13 @@ Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt | Dịch vụ | Gói miễn phí | Trường hợp sử dụng | | --- | --- | --- | | **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) | -| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc | +| **Volcengine CodingPlan** | ¥9.9/tháng đầu | Tốt nhất cho người dùng Trung Quốc, nhiều mô hình SOTA (Doubao, DeepSeek, v.v.) | +| **Zhipu** | 200K tokens/tháng | Phù hợp cho người dùng Trung Quốc | | **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web | | **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) | + +--- + +
+ PicoClaw Meme +
diff --git a/README.zh.md b/README.zh.md index c744e0d20..6395b1e7b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,5 +1,5 @@
-PicoClaw +PicoClaw

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

@@ -208,9 +208,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d ### 🚀 快速开始 > [!TIP] -> 在 `~/.picoclaw/config.json` 中设置您的 API Key。 -> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) -> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询) +> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。 **1. 初始化 (Initialize)** @@ -226,7 +224,7 @@ picoclaw onboard "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model_name": "gpt4", + "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -234,8 +232,13 @@ picoclaw onboard }, "model_list": [ { - "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "your-api-key", "request_timeout": 300 }, @@ -365,7 +368,6 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) ├── IDENTITY.md # Agent 身份设定 ├── SOUL.md # Agent 灵魂/性格 -├── TOOLS.md # 工具描述 └── USER.md # 用户偏好 ``` @@ -515,8 +517,10 @@ Agent 读取 HEARTBEAT.md | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | -| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | +| **火山引擎(Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | +| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) | +| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) | | **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | @@ -526,8 +530,13 @@ Agent 读取 HEARTBEAT.md { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", + "api_key": "sk-your-api-key" + }, + { + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key" }, { @@ -543,7 +552,7 @@ Agent 读取 HEARTBEAT.md ], "agents": { "defaults": { - "model": "gpt-5.2" + "model": "gpt-5.4" } } } @@ -555,8 +564,18 @@ Agent 读取 HEARTBEAT.md ```json { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", + "api_key": "sk-..." +} +``` + +**火山引擎(Doubao)** + +```json +{ + "model_name": "ark-code-latest", + "model": "volcengine/ark-code-latest", "api_key": "sk-..." } ``` @@ -622,14 +641,14 @@ Agent 读取 HEARTBEAT.md { "model_list": [ { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api1.example.com/v1", "api_key": "sk-key1" }, { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_base": "https://api2.example.com/v1", "api_key": "sk-key2" } @@ -875,7 +894,15 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) | 服务 | 免费层级 | 适用场景 | | --- | --- | --- | | **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | -| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 | +| **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型(豆包、DeepSeek 等) | +| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 | | **Brave Search** | 2000 次查询/月 | 网络搜索功能 | | **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 | | **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | +| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) | + +--- + +
+ PicoClaw Meme +
diff --git a/assets/logo.webp b/assets/logo.webp new file mode 100644 index 000000000..9333f7e1b Binary files /dev/null and b/assets/logo.webp differ diff --git a/assets/wechat.png b/assets/wechat.png index cc88186a8..4cfcbbb1a 100644 Binary files a/assets/wechat.png and b/assets/wechat.png differ diff --git a/cmd/picoclaw-launcher-tui/internal/ui/app.go b/cmd/picoclaw-launcher-tui/internal/ui/app.go index 8628afab3..a2ccddf70 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/app.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/app.go @@ -1,6 +1,7 @@ package ui import ( + "fmt" "os" "os/exec" "path/filepath" @@ -67,6 +68,7 @@ func Run() error { root := tview.NewFlex().SetDirection(tview.FlexRow) root.AddItem(bannerView(), 6, 0, false) root.AddItem(state.pages, 0, 1, true) + root.AddItem(footerView(), 1, 0, false) if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil { return err @@ -102,7 +104,7 @@ func (s *appState) pop() { } func (s *appState) mainMenu() tview.Primitive { - menu := NewMenu("Config Menu", nil) + menu := NewMenu("Menu", nil) refreshMainMenu(menu, s) menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { switch event.Key() { @@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive { s.requestExit() return nil } - if event.Rune() == 'q' { - s.requestExit() - return nil - } + return event }) @@ -131,6 +130,32 @@ func (s *appState) refreshMenu(name string, menu *Menu) { } } +func (s *appState) countChannels() (enabled int, total int) { + c := s.config.Channels + entries := []bool{ + c.Telegram.Enabled, + c.Discord.Enabled, + c.QQ.Enabled, + c.MaixCam.Enabled, + c.WhatsApp.Enabled, + c.Feishu.Enabled, + c.DingTalk.Enabled, + c.Slack.Enabled, + c.Matrix.Enabled, + c.LINE.Enabled, + c.OneBot.Enabled, + c.WeCom.Enabled, + c.WeComApp.Enabled, + } + total = len(entries) + for _, v := range entries { + if v { + enabled++ + } + } + return enabled, total +} + func refreshMainMenuIfPresent(s *appState) { if menu, ok := s.menus["main"]; ok { refreshMainMenu(menu, s) @@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) { selectedModel := s.selectedModelName() modelReady := selectedModel != "" channelReady := s.hasEnabledChannel() + enabledCount, totalChannels := s.countChannels() gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() gatewayLabel := "Start Gateway" @@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) { items := []MenuItem{ { Label: rootModelLabel(selectedModel), - Description: rootModelDescription(selectedModel), + Description: rootModelDescription(), Action: func() { s.push("model", s.modelMenu()) }, @@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) { }, { Label: rootChannelLabel(channelReady), - Description: rootChannelDescription(channelReady), + Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels), Action: func() { s.push("channel", s.channelMenu()) }, @@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string { func rootModelLabel(selected string) string { if selected == "" { - return "Model (no model selected)" + return "Model (None)" } return "Model (" + selected + ")" } -func rootModelDescription(selected string) string { - if selected == "" { - return "no model selected" - } - return "selected" +func rootModelDescription() string { + return "Using SPACE to choose your model" } func rootChannelLabel(valid bool) string { @@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string { return "Channel" } -func rootChannelDescription(valid bool) string { - if !valid { - return "no channel enabled" - } - return "enabled" -} - func (s *appState) startTalk() { if !s.isActiveModelValid() { s.showMessage("Model required", "Select a valid model before starting talk") diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go index 16b7d053b..2f28af123 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go @@ -12,7 +12,6 @@ import ( func (s *appState) buildChannelMenuItems() []MenuItem { return []MenuItem{ - {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, channelItem( "Telegram", "Telegram bot settings", @@ -101,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive { s.pop() return nil } - if event.Rune() == 'q' { - s.pop() - return nil - } return event }) return menu diff --git a/cmd/picoclaw-launcher-tui/internal/ui/model.go b/cmd/picoclaw-launcher-tui/internal/ui/model.go index 304b4efa7..698502058 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/model.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/model.go @@ -14,23 +14,7 @@ import ( ) func (s *appState) modelMenu() tview.Primitive { - items := make([]MenuItem, 0, 2+len(s.config.ModelList)) - items = append(items, - MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, - MenuItem{ - Label: "Add model", - Description: "Append a new model entry", - Action: func() { - s.addModel( - picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"}, - ) - s.push( - fmt.Sprintf("model-%d", len(s.config.ModelList)-1), - s.modelForm(len(s.config.ModelList)-1), - ) - }, - }, - ) + items := make([]MenuItem, 0, 1+len(s.config.ModelList)) currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) for i := range s.config.ModelList { index := i @@ -57,6 +41,23 @@ func (s *appState) modelMenu() tview.Primitive { }, }) } + // Add model entry appended at the end so the models map to rows 1..N + items = append(items, + MenuItem{ + Label: "**Add model**", + Description: "Append a new model entry", + Action: func() { + newName := s.nextAvailableModelName("new-model") + s.addModel( + picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"}, + ) + s.push( + fmt.Sprintf("model-%d", len(s.config.ModelList)-1), + s.modelForm(len(s.config.ModelList)-1), + ) + }, + }, + ) menu := NewMenu("Models", items) menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { @@ -64,14 +65,11 @@ func (s *appState) modelMenu() tview.Primitive { s.pop() return nil } - if event.Rune() == 'q' { - s.pop() - return nil - } + if event.Rune() == ' ' { row, _ := menu.GetSelection() - if row > 0 && row <= len(s.config.ModelList) { - model := s.config.ModelList[row-1] + if row >= 0 && row < len(s.config.ModelList) { + model := s.config.ModelList[row] if !isModelValid(model) { s.showMessage( "Invalid model", @@ -95,12 +93,23 @@ func (s *appState) modelForm(index int) tview.Primitive { model := &s.config.ModelList[index] form := tview.NewForm() form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) - form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123)) - form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22)) addInput(form, "Model Name", model.ModelName, func(value string) { + if value == "" { + s.showMessage("Invalid model name", "Model Name cannot be empty") + return + } + if s.modelNameExists(value, index) { + s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value)) + return + } + oldName := model.ModelName model.ModelName = value + if s.config.Agents.Defaults.Model == oldName { + s.config.Agents.Defaults.Model = value + } s.dirty = true + form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) refreshMainMenuIfPresent(s) if menu, ok := s.menus["model"]; ok { refreshModelMenuFromState(menu, s) @@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive { }) form.AddButton("Delete", func() { - s.deleteModel(index) + pageName := "confirm-delete-model" + if s.pages.HasPage(pageName) { + return + } + modal := tview.NewModal(). + SetText("Are you sure you want to delete this model?"). + AddButtons([]string{"Cancel", "Delete"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + s.pages.RemovePage(pageName) + if buttonLabel == "Delete" { + s.deleteModel(index) + } + }) + modal.SetTitle("Confirm Delete").SetBorder(true) + s.pages.AddPage(pageName, modal, true, true) }) form.AddButton("Test", func() { s.testModel(model) @@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color { func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) { for i, model := range models { - row := i + 1 + row := i label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) isValid := isModelValid(model) if model.ModelName == currentModel && currentModel != "" { @@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M } func refreshModelMenuFromState(menu *Menu, s *appState) { - items := make([]MenuItem, 0, 2+len(s.config.ModelList)) - items = append(items, - MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }}, - MenuItem{ - Label: "Add model", - Description: "Append a new model entry", - Action: func() { - s.addModel( - picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"}, - ) - s.push( - fmt.Sprintf("model-%d", len(s.config.ModelList)-1), - s.modelForm(len(s.config.ModelList)-1), - ) - }, - }, - ) + items := make([]MenuItem, 0, 1+len(s.config.ModelList)) currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) for i := range s.config.ModelList { index := i @@ -277,6 +284,19 @@ func refreshModelMenuFromState(menu *Menu, s *appState) { }, }) } + items = append(items, + MenuItem{ + Label: "**Add Model**", + Description: "Append a new model entry", + Action: func() { + newName := s.nextAvailableModelName("new-model") + s.addModel( + picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.4"}, + ) + s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1)) + }, + }, + ) menu.applyItems(items) } @@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool { return hasKey && hasModel } +func (s *appState) modelNameExists(name string, excludeIndex int) bool { + target := strings.TrimSpace(name) + if target == "" { + return false + } + for i := range s.config.ModelList { + if i == excludeIndex { + continue + } + if strings.TrimSpace(s.config.ModelList[i].ModelName) == target { + return true + } + } + return false +} + +func (s *appState) nextAvailableModelName(base string) string { + name := strings.TrimSpace(base) + if name == "" { + name = "new-model" + } + if !s.modelNameExists(name, -1) { + return name + } + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s-%d", name, i) + if !s.modelNameExists(candidate, -1) { + return candidate + } + } +} + func (s *appState) testModel(model *picoclawconfig.ModelConfig) { if model == nil { return diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go index 68cdd60b9..da3c3526d 100644 --- a/cmd/picoclaw-launcher-tui/internal/ui/style.go +++ b/cmd/picoclaw-launcher-tui/internal/ui/style.go @@ -41,3 +41,15 @@ func bannerView() *tview.TextView { text.SetBorder(false) return text } + +const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch" + +func footerView() *tview.TextView { + text := tview.NewTextView() + text.SetTextAlign(tview.AlignCenter) + text.SetText(footerText) + text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor) + text.SetTextColor(tview.Styles.PrimaryTextColor) + text.SetBorder(false) + return text +} diff --git a/cmd/picoclaw/internal/agent/command.go b/cmd/picoclaw/internal/agent/command.go index 8939ddb2c..47262fc85 100644 --- a/cmd/picoclaw/internal/agent/command.go +++ b/cmd/picoclaw/internal/agent/command.go @@ -6,11 +6,10 @@ import ( func NewAgentCommand() *cobra.Command { var ( - message string - sessionKey string - model string - debug bool - orchestrationEnabled bool + message string + sessionKey string + model string + debug bool ) cmd := &cobra.Command{ @@ -18,7 +17,7 @@ func NewAgentCommand() *cobra.Command { Short: "Interact with the agent directly", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return agentCmd(message, sessionKey, model, debug, orchestrationEnabled) + return agentCmd(message, sessionKey, model, debug) }, } @@ -26,7 +25,6 @@ func NewAgentCommand() *cobra.Command { cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)") cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key") cmd.Flags().StringVarP(&model, "model", "", "", "Model to use") - cmd.Flags().BoolVar(&orchestrationEnabled, "orchestration", false, "Enable orchestration mode") return cmd } diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index fab1bc54c..a995945d2 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -18,7 +18,7 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) -func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled bool) error { +func agentCmd(message, sessionKey, model string, debug bool) error { if sessionKey == "" { sessionKey = "cli:default" } @@ -37,10 +37,6 @@ func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled boo cfg.Agents.Defaults.ModelName = model } - if orchestrationEnabled { - cfg.Agents.Defaults.Orchestration = true - } - provider, modelID, err := providers.CreateProvider(cfg) if err != nil { return fmt.Errorf("error creating provider: %w", err) @@ -54,6 +50,7 @@ func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled boo msgBus := bus.NewMessageBus() defer msgBus.Close() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + defer agentLoop.Close() // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index a0a229167..4bf132685 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -72,14 +72,14 @@ func authLoginOpenAI(useDeviceCode bool) error { // If no openai in ModelList, add it if !foundOpenAI { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ - ModelName: "gpt-5.2", - Model: "openai/gpt-5.2", + ModelName: "gpt-5.4", + Model: "openai/gpt-5.4", AuthMethod: "oauth", }) } // Update default model to use OpenAI - appCfg.Agents.Defaults.ModelName = "gpt-5.2" + appCfg.Agents.Defaults.ModelName = "gpt-5.4" if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { return fmt.Errorf("could not update config: %w", err) @@ -90,7 +90,7 @@ func authLoginOpenAI(useDeviceCode bool) error { if cred.AccountID != "" { fmt.Printf("Account: %s\n", cred.AccountID) } - fmt.Println("Default model set to: gpt-5.2") + fmt.Println("Default model set to: gpt-5.4") return nil } @@ -318,13 +318,13 @@ func authLoginPasteToken(provider string) error { } if !found { appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ - ModelName: "gpt-5.2", - Model: "openai/gpt-5.2", + ModelName: "gpt-5.4", + Model: "openai/gpt-5.4", AuthMethod: "token", }) } // Update default model - appCfg.Agents.Defaults.ModelName = "gpt-5.2" + appCfg.Agents.Defaults.ModelName = "gpt-5.4" } if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { return fmt.Errorf("could not update config: %w", err) diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go index 35976ff10..5e42a677f 100644 --- a/cmd/picoclaw/internal/gateway/command.go +++ b/cmd/picoclaw/internal/gateway/command.go @@ -1,29 +1,46 @@ package gateway import ( + "fmt" + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" ) func NewGatewayCommand() *cobra.Command { - var ( - debug bool - orchestration bool - enableStats bool - ) + var debug bool + var noTruncate bool + var orchestration bool + var enableStats bool cmd := &cobra.Command{ Use: "gateway", Aliases: []string{"g"}, Short: "Start picoclaw gateway", Args: cobra.NoArgs, + PreRunE: func(_ *cobra.Command, _ []string) error { + if noTruncate && !debug { + return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)") + } + + if noTruncate { + utils.SetDisableTruncation(true) + logger.Info("String truncation is globally disabled via 'no-truncate' flag") + } + + return nil + }, RunE: func(_ *cobra.Command, _ []string) error { return gatewayCmd(debug, orchestration, enableStats) }, } cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") + cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs") cmd.Flags().BoolVar(&orchestration, "orchestration", false, "Enable subagent orchestration") - cmd.Flags().BoolVar(&enableStats, "stats", false, "Enable stats tracking") + cmd.Flags().BoolVar(&enableStats, "stats", false, "Enable stats collection") return cmd } diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go index f81d7013d..e04bccffb 100644 --- a/cmd/picoclaw/internal/helpers.go +++ b/cmd/picoclaw/internal/helpers.go @@ -1,23 +1,14 @@ package internal import ( - "fmt" "os" "path/filepath" - "runtime" "github.com/sipeed/picoclaw/pkg/config" ) const Logo = "🦞" -var ( - version = "dev" - gitCommit string - buildTime string - goVersion string -) - // GetPicoclawHome returns the picoclaw home directory. // Priority: $PICOCLAW_HOME > ~/.picoclaw func GetPicoclawHome() string { @@ -40,25 +31,19 @@ func LoadConfig() (*config.Config, error) { } // FormatVersion returns the version string with optional git commit +// Deprecated: Use pkg/config.FormatVersion instead func FormatVersion() string { - v := version - if gitCommit != "" { - v += fmt.Sprintf(" (git: %s)", gitCommit) - } - return v + return config.FormatVersion() } // FormatBuildInfo returns build time and go version info +// Deprecated: Use pkg/config.FormatBuildInfo instead func FormatBuildInfo() (string, string) { - build := buildTime - goVer := goVersion - if goVer == "" { - goVer = runtime.Version() - } - return build, goVer + return config.FormatBuildInfo() } // GetVersion returns the version string +// Deprecated: Use pkg/config.GetVersion instead func GetVersion() string { - return version + return config.GetVersion() } diff --git a/cmd/picoclaw/internal/helpers_ext_test.go b/cmd/picoclaw/internal/helpers_ext_test.go new file mode 100644 index 000000000..fafa63be8 --- /dev/null +++ b/cmd/picoclaw/internal/helpers_ext_test.go @@ -0,0 +1,83 @@ +package internal + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestFormatVersion_NoGitCommit(t *testing.T) { + oldVersion, oldGit := config.Version, config.GitCommit + t.Cleanup(func() { config.Version, config.GitCommit = oldVersion, oldGit }) + + config.Version = "1.2.3" + config.GitCommit = "" + + assert.Equal(t, "1.2.3", FormatVersion()) +} + +func TestFormatVersion_WithGitCommit(t *testing.T) { + oldVersion, oldGit := config.Version, config.GitCommit + t.Cleanup(func() { config.Version, config.GitCommit = oldVersion, oldGit }) + + config.Version = "1.2.3" + config.GitCommit = "abc123" + + assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion()) +} + +func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) { + oldBuildTime, oldGoVersion := config.BuildTime, config.GoVersion + t.Cleanup(func() { config.BuildTime, config.GoVersion = oldBuildTime, oldGoVersion }) + + config.BuildTime = "2026-02-20T00:00:00Z" + config.GoVersion = "go1.23.0" + + build, goVer := FormatBuildInfo() + + assert.Equal(t, config.BuildTime, build) + assert.Equal(t, config.GoVersion, goVer) +} + +func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) { + oldBuildTime, oldGoVersion := config.BuildTime, config.GoVersion + t.Cleanup(func() { config.BuildTime, config.GoVersion = oldBuildTime, oldGoVersion }) + + config.BuildTime = "" + config.GoVersion = "go1.23.0" + + build, goVer := FormatBuildInfo() + + assert.Empty(t, build) + assert.Equal(t, config.GoVersion, goVer) +} + +func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) { + oldBuildTime, oldGoVersion := config.BuildTime, config.GoVersion + t.Cleanup(func() { config.BuildTime, config.GoVersion = oldBuildTime, oldGoVersion }) + + config.BuildTime = "x" + config.GoVersion = "" + + build, goVer := FormatBuildInfo() + + assert.Equal(t, "x", build) + assert.Equal(t, runtime.Version(), goVer) +} + +func TestGetVersion(t *testing.T) { + assert.Equal(t, "dev", GetVersion()) +} + +func TestGetConfigPath_WithEnv(t *testing.T) { + t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json") + t.Setenv("HOME", "/tmp/home") + + got := GetConfigPath() + want := "/tmp/custom/config.json" + + assert.Equal(t, want, got) +} diff --git a/cmd/picoclaw/internal/helpers_test.go b/cmd/picoclaw/internal/helpers_test.go index 646be1ba1..583751781 100644 --- a/cmd/picoclaw/internal/helpers_test.go +++ b/cmd/picoclaw/internal/helpers_test.go @@ -40,65 +40,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) { assert.Equal(t, want, got) } -func TestFormatVersion_NoGitCommit(t *testing.T) { - oldVersion, oldGit := version, gitCommit - t.Cleanup(func() { version, gitCommit = oldVersion, oldGit }) - - version = "1.2.3" - gitCommit = "" - - assert.Equal(t, "1.2.3", FormatVersion()) -} - -func TestFormatVersion_WithGitCommit(t *testing.T) { - oldVersion, oldGit := version, gitCommit - t.Cleanup(func() { version, gitCommit = oldVersion, oldGit }) - - version = "1.2.3" - gitCommit = "abc123" - - assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion()) -} - -func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) { - oldBuildTime, oldGoVersion := buildTime, goVersion - t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion }) - - buildTime = "2026-02-20T00:00:00Z" - goVersion = "go1.23.0" - - build, goVer := FormatBuildInfo() - - assert.Equal(t, buildTime, build) - assert.Equal(t, goVersion, goVer) -} - -func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) { - oldBuildTime, oldGoVersion := buildTime, goVersion - t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion }) - - buildTime = "" - goVersion = "go1.23.0" - - build, goVer := FormatBuildInfo() - - assert.Empty(t, build) - assert.Equal(t, goVersion, goVer) -} - -func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) { - oldBuildTime, oldGoVersion := buildTime, goVersion - t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion }) - - buildTime = "x" - goVersion = "" - - build, goVer := FormatBuildInfo() - - assert.Equal(t, "x", build) - assert.Equal(t, runtime.Version(), goVer) -} - func TestGetConfigPath_Windows(t *testing.T) { if runtime.GOOS != "windows" { t.Skip("windows-specific HOME behavior varies; run on windows") @@ -112,17 +53,3 @@ func TestGetConfigPath_Windows(t *testing.T) { require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want) } - -func TestGetVersion(t *testing.T) { - assert.Equal(t, "dev", GetVersion()) -} - -func TestGetConfigPath_WithEnv(t *testing.T) { - t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json") - t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred - - got := GetConfigPath() - want := "/tmp/custom/config.json" - - assert.Equal(t, want, got) -} diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go index e89c15ab7..ec1012959 100644 --- a/cmd/picoclaw/internal/onboard/command.go +++ b/cmd/picoclaw/internal/onboard/command.go @@ -1,6 +1,14 @@ package onboard -import "github.com/spf13/cobra" +import ( + "embed" + + "github.com/spf13/cobra" +) + +//go:generate cp -r ../../../../workspace . +//go:embed workspace +var embeddedFiles embed.FS func NewOnboardCommand() *cobra.Command { cmd := &cobra.Command{ diff --git a/cmd/picoclaw/internal/onboard/helpers.go b/cmd/picoclaw/internal/onboard/helpers.go index 55bbd7de9..4db8bdc8b 100644 --- a/cmd/picoclaw/internal/onboard/helpers.go +++ b/cmd/picoclaw/internal/onboard/helpers.go @@ -2,6 +2,7 @@ package onboard import ( "fmt" + "io/fs" "os" "path/filepath" @@ -9,30 +10,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) -var workspaceTemplates = map[string]string{ - "AGENTS.md": `# Agent Instructions - -You are a helpful AI assistant. Be concise, accurate, and friendly. -`, - "IDENTITY.md": `# Identity - -## Name -PicoClaw 🦞 -`, - "SOUL.md": `# Soul - -I am picoclaw, a lightweight AI assistant powered by AI. -`, - "USER.md": `# User - -Information about user goes here. -`, - "memory/MEMORY.md": `# Long-term Memory - -This file stores important information that should persist across sessions. -`, -} - func onboard() { configPath := internal.GetConfigPath() @@ -77,19 +54,48 @@ func createWorkspaceTemplates(workspace string) { } func copyEmbeddedToTarget(targetDir string) error { + // Ensure target directory exists if err := os.MkdirAll(targetDir, 0o755); err != nil { - return fmt.Errorf("failed to create target directory: %w", err) + return fmt.Errorf("Failed to create target directory: %w", err) } - for relPath, content := range workspaceTemplates { - targetPath := filepath.Join(targetDir, relPath) + // Walk through all files in embed.FS + err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip directories + if d.IsDir() { + return nil + } + + // Read embedded file + data, err := embeddedFiles.ReadFile(path) + if err != nil { + return fmt.Errorf("Failed to read embedded file %s: %w", path, err) + } + + new_path, err := filepath.Rel("workspace", path) + if err != nil { + return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err) + } + + // Build target file path + targetPath := filepath.Join(targetDir, new_path) + + // Ensure target file's directory exists if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { - return fmt.Errorf("failed to create directory %s: %w", filepath.Dir(targetPath), err) + return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err) } - if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil { - return fmt.Errorf("failed to write file %s: %w", targetPath, err) - } - } - return nil + // Write file + if err := os.WriteFile(targetPath, data, 0o644); err != nil { + return fmt.Errorf("Failed to write file %s: %w", targetPath, err) + } + + return nil + }) + + return err } diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go index ab28f4885..dd7063fe6 100644 --- a/cmd/picoclaw/internal/status/helpers.go +++ b/cmd/picoclaw/internal/status/helpers.go @@ -6,6 +6,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" ) func statusCmd() { @@ -18,8 +19,8 @@ func statusCmd() { configPath := internal.GetConfigPath() fmt.Printf("%s picoclaw Status\n", internal.Logo) - fmt.Printf("Version: %s\n", internal.FormatVersion()) - build, _ := internal.FormatBuildInfo() + fmt.Printf("Version: %s\n", config.FormatVersion()) + build, _ := config.FormatBuildInfo() if build != "" { fmt.Printf("Build: %s\n", build) } diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go index 1cf686671..71c7dd2f8 100644 --- a/cmd/picoclaw/internal/version/command.go +++ b/cmd/picoclaw/internal/version/command.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" ) func NewVersionCommand() *cobra.Command { @@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command { } func printVersion() { - fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion()) - build, goVer := internal.FormatBuildInfo() + fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion()) + build, goVer := config.FormatBuildInfo() if build != "" { fmt.Printf(" Build: %s\n", build) } diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index d9263462e..b82475905 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -22,15 +22,16 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version" + "github.com/sipeed/picoclaw/pkg/config" ) func NewPicoclawCommand() *cobra.Command { - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion()) + short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion()) cmd := &cobra.Command{ Use: "picoclaw", Short: short, - Example: "picoclaw list", + Example: "picoclaw version", } cmd.AddCommand( diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index 3740ba358..e622675ee 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/config" ) func TestNewPicoclawCommand(t *testing.T) { @@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) { require.NotNil(t, cmd) - short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion()) + short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion()) assert.Equal(t, "picoclaw", cmd.Use) assert.Equal(t, short, cmd.Short) diff --git a/config/config.example.json b/config/config.example.json index 0e2cae8e5..b259df6f6 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -3,7 +3,7 @@ "defaults": { "workspace": "~/.picoclaw/workspace", "restrict_to_workspace": true, - "model_name": "gpt4", + "model_name": "gpt-5.4", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20, @@ -13,8 +13,8 @@ }, "model_list": [ { - "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key", "api_base": "https://api.openai.com/v1" }, @@ -36,14 +36,19 @@ "api_key": "sk-your-deepseek-key" }, { - "model_name": "loadbalanced-gpt4", - "model": "openai/gpt-5.2", + "model_name": "longcat", + "model": "longcat/LongCat-Flash-Thinking", + "api_key": "your-longcat-api-key" + }, + { + "model_name": "loadbalanced-gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-key1", "api_base": "https://api1.example.com/v1" }, { - "model_name": "loadbalanced-gpt4", - "model": "openai/gpt-5.2", + "model_name": "loadbalanced-gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-key2", "api_base": "https://api2.example.com/v1" } @@ -194,8 +199,13 @@ "nickserv_password": "", "sasl_user": "", "sasl_password": "", - "channels": ["#mychannel"], - "request_caps": ["server-time", "message-tags"], + "channels": [ + "#mychannel" + ], + "request_caps": [ + "server-time", + "message-tags" + ], "allow_from": [], "group_trigger": { "mention_only": true @@ -269,6 +279,10 @@ "avian": { "api_key": "", "api_base": "https://api.avian.io/v1" + }, + "longcat": { + "api_key": "", + "api_base": "https://api.longcat.chat/openai" } }, "tools": { @@ -279,6 +293,9 @@ "brave": { "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", + "api_keys": [ + "YOUR_BRAVE_API_KEY" + ], "max_results": 5 }, "tavily": { @@ -293,7 +310,10 @@ }, "perplexity": { "enabled": false, - "api_key": "", + "api_key": "pplx-xxx", + "api_keys": [ + "pplx-xxx" + ], "max_results": 5 }, "searxng": { @@ -316,6 +336,13 @@ }, "mcp": { "enabled": false, + "discovery": { + "enabled": false, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, "servers": { "context7": { "enabled": false, @@ -459,6 +486,9 @@ "enabled": false, "monitor_usb": true }, + "voice": { + "echo_transcription": false + }, "gateway": { "host": "127.0.0.1", "port": 18790 diff --git a/docker/Dockerfile.goreleaser.launcher b/docker/Dockerfile.goreleaser.launcher new file mode 100644 index 000000000..5d65576f7 --- /dev/null +++ b/docker/Dockerfile.goreleaser.launcher @@ -0,0 +1,12 @@ +FROM alpine:3.21 + +ARG TARGETPLATFORM + +RUN apk add --no-cache ca-certificates tzdata + +COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw +COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher +COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui + +ENTRYPOINT ["picoclaw-launcher"] +CMD ["-public", "-no-browser"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9ec71abab..b26cf4199 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -19,7 +19,7 @@ services: # ───────────────────────────────────────────── # PicoClaw Gateway (Long-running Bot) - # docker compose -f docker/docker-compose.yml up picoclaw-gateway + # docker compose -f docker/docker-compose.yml --profile gateway up # ───────────────────────────────────────────── picoclaw-gateway: image: docker.io/sipeed/picoclaw:latest @@ -32,3 +32,21 @@ services: # - "host.docker.internal:host-gateway" volumes: - ./data:/root/.picoclaw + + # ───────────────────────────────────────────── + # PicoClaw Launcher (Web Console + Gateway) + # docker compose -f docker/docker-compose.yml --profile launcher up + # ───────────────────────────────────────────── + picoclaw-launcher: + image: docker.io/sipeed/picoclaw:launcher + container_name: picoclaw-launcher + restart: on-failure + profiles: + - launcher + environment: + - PICOCLAW_GATEWAY_HOST=0.0.0.0 + ports: + - "127.0.0.1:18800:18800" + - "127.0.0.1:18790:18790" + volumes: + - ./data:/root/.picoclaw diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md index c213aa80b..233f5c0a3 100644 --- a/docs/channels/matrix/README.md +++ b/docs/channels/matrix/README.md @@ -22,7 +22,8 @@ Add this to `config.json`: "enabled": true, "text": "Thinking..." }, - "reasoning_channel_id": "" + "reasoning_channel_id": "", + "message_format": "richtext" } } } @@ -42,10 +43,12 @@ Add this to `config.json`: | group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) | | placeholder | object | No | Placeholder message config | | reasoning_channel_id | string | No | Target channel for reasoning output | +| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only | ## 3. Currently Supported -- Text message send/receive +- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.) +- Configurable message format (`richtext` / `plain`) - Incoming image/audio/video/file download (MediaStore first, local path fallback) - Incoming audio normalization into existing transcription flow (`[audio: ...]`) - Outgoing image/audio/video/file upload and send diff --git a/docs/debug.md b/docs/debug.md new file mode 100644 index 000000000..7e28a15f2 --- /dev/null +++ b/docs/debug.md @@ -0,0 +1,33 @@ +# Debugging PicoClaw + +PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates. +## Starting PicoClaw in Debug Mode + +To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag: + +```bash +picoclaw gateway --debug +# or +picoclaw gateway -d +``` + +In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results. + +## Disabling Log Truncation (Full Logs) + +By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable. + +If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag. + +**Note:** This flag *only* works when combined with the `--debug` mode. + +```bash +picoclaw gateway --debug --no-truncate + +``` + +When this flag is active, the global truncation function is disabled. This is extremely useful for: + +* Verifying the exact syntax of the messages sent to the provider. +* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`. +* Debugging the session history saved in memory. diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md index a214d9857..38f379c50 100644 --- a/docs/design/provider-refactoring.md +++ b/docs/design/provider-refactoring.md @@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity. Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design: 1. **Model-centric**: Users care about models, not providers -2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6` +2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6` 3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes ### 2.2 New Configuration Structure @@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design: "api_key": "sk-xxx" }, { - "model_name": "gpt-5.2", - "model": "openai/gpt-5.2", + "model_name": "gpt-5.4", + "model": "openai/gpt-5.4", "api_key": "sk-xxx" }, { @@ -128,7 +128,7 @@ type Config struct { type ModelConfig struct { // Required ModelName string `json:"model_name"` // user-facing name (alias) - Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2 + Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4 // Common config APIBase string `json:"api_base,omitempty"` @@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field: "model": "deepseek-chat" }, "coder": { - "model": "gpt-5.2", + "model": "gpt-5.4", "system_prompt": "You are a coding assistant..." }, "translator": { @@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_ model_list: - model_name: gpt-4o litellm_params: - model: openai/gpt-5.2 + model: openai/gpt-5.4 api_key: xxx - model_name: my-custom litellm_params: diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md index 0d4af719c..eed228d4d 100644 --- a/docs/migration/model-list-migration.md +++ b/docs/migration/model-list-migration.md @@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages: "agents": { "defaults": { "provider": "openai", - "model": "gpt-5.2" + "model": "gpt-5.4" } } } @@ -53,7 +53,7 @@ The new `model_list` configuration offers several advantages: "model_list": [ { "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model": "openai/gpt-5.4", "api_key": "sk-your-openai-key", "api_base": "https://api.openai.com/v1" }, @@ -82,7 +82,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | Prefix | Description | Example | |--------|-------------|---------| -| `openai/` | OpenAI API (default) | `openai/gpt-5.2` | +| `openai/` | OpenAI API (default) | `openai/gpt-5.4` | | `anthropic/` | Anthropic API | `anthropic/claude-opus-4` | | `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` | | `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` | @@ -109,7 +109,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier` | Field | Required | Description | |-------|----------|-------------| | `model_name` | Yes | User-facing alias for the model | -| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) | +| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) | | `api_base` | No | API endpoint URL | | `api_key` | No* | API authentication key | | `proxy` | No | HTTP proxy URL | @@ -130,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load: "model_list": [ { "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model": "openai/gpt-5.4", "api_key": "sk-key1", "api_base": "https://api1.example.com/v1" }, { "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model": "openai/gpt-5.4", "api_key": "sk-key2", "api_base": "https://api2.example.com/v1" }, { "model_name": "gpt4", - "model": "openai/gpt-5.2", + "model": "openai/gpt-5.4", "api_key": "sk-key3", "api_base": "https://api3.example.com/v1" } diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md index e64a3a107..8c8eb31f0 100644 --- a/docs/tools_configuration.md +++ b/docs/tools_configuration.md @@ -7,11 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`. ```json { "tools": { - "web": { ... }, - "mcp": { ... }, - "exec": { ... }, - "cron": { ... }, - "skills": { ... } + "web": { + ... + }, + "mcp": { + ... + }, + "exec": { + ... + }, + "cron": { + ... + }, + "skills": { + ... + } } } ``` @@ -23,7 +33,7 @@ Web tools are used for web search and fetching. ### Brave | Config | Type | Default | Description | -| ------------- | ------ | ------- | ------------------------- | +|---------------|--------|---------|---------------------------| | `enabled` | bool | false | Enable Brave search | | `api_key` | string | - | Brave Search API key | | `max_results` | int | 5 | Maximum number of results | @@ -31,14 +41,14 @@ Web tools are used for web search and fetching. ### DuckDuckGo | Config | Type | Default | Description | -| ------------- | ---- | ------- | ------------------------- | +|---------------|------|---------|---------------------------| | `enabled` | bool | true | Enable DuckDuckGo search | | `max_results` | int | 5 | Maximum number of results | ### Perplexity | Config | Type | Default | Description | -| ------------- | ------ | ------- | ------------------------- | +|---------------|--------|---------|---------------------------| | `enabled` | bool | false | Enable Perplexity search | | `api_key` | string | - | Perplexity API key | | `max_results` | int | 5 | Maximum number of results | @@ -48,7 +58,7 @@ Web tools are used for web search and fetching. The exec tool is used to execute shell commands. | Config | Type | Default | Description | -| ---------------------- | ----- | ------- | ------------------------------------------ | +|------------------------|-------|---------|--------------------------------------------| | `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | | `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | @@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands: "tools": { "exec": { "enable_deny_patterns": true, - "custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"] + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ] } } } @@ -92,24 +105,47 @@ By default, PicoClaw blocks the following dangerous commands: The cron tool is used for scheduling periodic tasks. | Config | Type | Default | Description | -| ---------------------- | ---- | ------- | ---------------------------------------------- | +|------------------------|------|---------|------------------------------------------------| | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | ## MCP Tool The MCP tool enables integration with external Model Context Protocol servers. +### Tool Discovery (Lazy Loading) + +When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window +and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default. + +Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex). +When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked" +and injected into the context for a configured number of turns (`ttl`). + ### Global Config -| Config | Type | Default | Description | -| --------- | ------ | ------- | ----------------------------------- | -| `enabled` | bool | false | Enable MCP integration globally | -| `servers` | object | `{}` | Map of server name to server config | +| Config | Type | Default | Description | +|-------------|--------|---------|----------------------------------------------| +| `enabled` | bool | false | Enable MCP integration globally | +| `discovery` | object | `{}` | Configuration for Tool Discovery (see below) | +| `servers` | object | `{}` | Map of server name to server config | + +### Discovery Config (`discovery`) + +| Config | Type | Default | Description | +|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------| +| `enabled` | bool | false | If true, MCP tools are hidden and loaded on-demand via search. If false, all tools are loaded | +| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked | +| `max_search_results` | int | 5 | Maximum number of tools returned per search query | +| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search | +| `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) | + +> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`), +> otherwise the application will fail to start. ### Per-Server Config | Config | Type | Required | Description | -| ---------- | ------ | -------- | ------------------------------------------ | +|------------|--------|----------|--------------------------------------------| | `enabled` | bool | yes | Enable this MCP server | | `type` | string | no | Transport type: `stdio`, `sse`, `http` | | `command` | string | stdio | Executable command for stdio transport | @@ -122,8 +158,8 @@ The MCP tool enables integration with external Model Context Protocol servers. ### Transport Behavior - If `type` is omitted, transport is auto-detected: - - `url` is set → `sse` - - `command` is set → `stdio` + - `url` is set → `sse` + - `command` is set → `stdio` - `http` and `sse` both use `url` + optional `headers`. - `env` and `env_file` are only applied to `stdio` servers. @@ -140,7 +176,11 @@ The MCP tool enables integration with external Model Context Protocol servers. "filesystem": { "enabled": true, "command": "npx", - "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + "args": [ + "-y", + "@modelcontextprotocol/server-filesystem", + "/tmp" + ] } } } @@ -170,20 +210,76 @@ The MCP tool enables integration with external Model Context Protocol servers. } ``` +#### 3) Massive MCP setup with Tool Discovery enabled + +*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools +dynamically only when requested by the user.* + +```json +{ + "tools": { + "mcp": { + "enabled": true, + "discovery": { + "enabled": true, + "ttl": 5, + "max_search_results": 5, + "use_bm25": true, + "use_regex": false + }, + "servers": { + "github": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-github" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN" + } + }, + "postgres": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-postgres", + "postgresql://user:password@localhost/dbname" + ] + }, + "slack": { + "enabled": true, + "command": "npx", + "args": [ + "-y", + "@modelcontextprotocol/server-slack" + ], + "env": { + "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN", + "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID" + } + } + } + } + } +} +``` + ## Skills Tool The skills tool configures skill discovery and installation via registries like ClawHub. ### Registries -| Config | Type | Default | Description | -| ---------------------------------- | ------ | -------------------- | ----------------------- | -| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | -| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | +| Config | Type | Default | Description | +|------------------------------------|--------|----------------------|----------------------------------------------| +| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | +| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | | `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits | -| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | -| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | -| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | +| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path | +| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path | +| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | ### Configuration Example @@ -217,4 +313,5 @@ For example: - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_MCP_ENABLED=true` -Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than environment variables. +Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than +environment variables. diff --git a/go.mod b/go.mod index f60be046f..a6d3de4b8 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/chzyer/readline v1.5.1 github.com/ergochat/irc-go v0.5.0 github.com/gdamore/tcell/v2 v2.13.8 + github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 @@ -28,6 +29,7 @@ require ( golang.org/x/oauth2 v0.35.0 golang.org/x/time v0.14.0 google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 maunium.net/go/mautrix v0.26.3 modernc.org/sqlite v1.46.1 ) @@ -59,7 +61,6 @@ require ( golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/term v0.40.0 // indirect golang.org/x/text v0.34.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.67.6 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 4060997f8..cdca4fc12 100644 --- a/go.sum +++ b/go.sum @@ -79,6 +79,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc= +github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -269,8 +271,6 @@ 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.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= diff --git a/pkg/agent/context.go b/pkg/agent/context.go index ce571ec7b..1680b5eaa 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -7,14 +7,17 @@ import ( "os" "path/filepath" "runtime" + "slices" "strings" "sync" "time" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" ) const orchestrationGuidance = `## Orchestration @@ -134,109 +137,100 @@ Maintain these sections in MEMORY.md under ## Orchestration: - **Decisions**: Key architectural/implementation decisions made during orchestration` type ContextBuilder struct { - workspace string - - workDir string // session-specific working directory (worktree or project subdir) - - skillsLoader *skills.SkillsLoader - - memory *MemoryStore - - tools *tools.ToolRegistry // Direct reference to tool registry - - peerNote string // set per-call from loop.go for peer session awareness - - orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used + workspace string + workDir string // session-specific working directory (worktree or project subdir) + skillsLoader *skills.SkillsLoader + memory *MemoryStore + tools *tools.ToolRegistry // Direct reference to tool registry + peerNote string // set per-call from loop.go for peer session awareness + orchestrationEnabled bool // set from AgentLoop when --orchestration flag is used + toolDiscoveryBM25 bool + toolDiscoveryRegex bool // Cache for system prompt to avoid rebuilding on every call. - // This fixes issue #607: repeated reprocessing of the entire context. - // The cache auto-invalidates when workspace source files change (mtime check). - - systemPromptMutex sync.RWMutex - + systemPromptMutex sync.RWMutex cachedSystemPrompt string - - cachedAt time.Time // max observed mtime across tracked paths at cache build time + cachedAt time.Time // max observed mtime across tracked paths at cache build time // existedAtCache tracks which source file paths existed the last time the - // cache was built. This lets sourceFilesChanged detect files that are newly - // created (didn't exist at cache time, now exist) or deleted (existed at - // cache time, now gone) — both of which should trigger a cache rebuild. - existedAtCache map[string]bool + + // skillFilesAtCache snapshots the skill tree file set and mtimes at cache + // build time. This catches nested file creations/deletions/mtime changes + // that may not update the top-level skill root directory mtime. + skillFilesAtCache map[string]time.Time +} + +func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder { + cb.toolDiscoveryBM25 = useBM25 + cb.toolDiscoveryRegex = useRegex + return cb } func getGlobalConfigDir() string { + if home := os.Getenv("PICOCLAW_HOME"); home != "" { + return home + } home, err := os.UserHomeDir() if err != nil { return "" } - return filepath.Join(home, ".picoclaw") } func NewContextBuilder(workspace string) *ContextBuilder { // builtin skills: skills directory in current project - // Use the skills/ directory under the current working directory - - wd, _ := os.Getwd() - - builtinSkillsDir := filepath.Join(wd, "skills") - + builtinSkillsDir := strings.TrimSpace(os.Getenv("PICOCLAW_BUILTIN_SKILLS")) + if builtinSkillsDir == "" { + wd, _ := os.Getwd() + builtinSkillsDir = filepath.Join(wd, "skills") + } globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") return &ContextBuilder{ - workspace: workspace, - + workspace: workspace, skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), - - memory: NewMemoryStore(workspace), + memory: NewMemoryStore(workspace), } } // SetToolsRegistry sets the tools registry for dynamic tool summary generation. - func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) { cb.tools = registry } // SetWorkDir sets the session-specific working directory (e.g., worktree path - // or project subdirectory). Bootstrap files found here take priority over workspace. - func (cb *ContextBuilder) SetWorkDir(dir string) { cb.workDir = dir } // SetPeerNote sets the peer session awareness note for the current call. - func (cb *ContextBuilder) SetPeerNote(note string) { cb.peerNote = note } // SetOrchestrationEnabled sets whether orchestration is enabled. - func (cb *ContextBuilder) SetOrchestrationEnabled(enabled bool) { cb.orchestrationEnabled = enabled } func (cb *ContextBuilder) getIdentity() string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) + toolDiscovery := cb.getDiscoveryRule() + version := config.FormatVersion() // Build tools section dynamically - toolsSection := cb.buildToolsSection() // Build prompt with optional orchestration banner - var prompt string - if cb.orchestrationEnabled { prompt = ` /_/_/_/_/_/_/_/_/_/_/_/_/_/_/ @@ -250,134 +244,75 @@ func (cb *ContextBuilder) getIdentity() string { } // Conditional identity and plan executing rule for orchestration mode - identity := "a helpful AI assistant" - executingRule := `Work through the current Phase's steps. - Mark each "- [x]" via edit_file. The system will auto-advance phases.` - if cb.orchestrationEnabled { identity = "a conductor AI agent that orchestrates subagents" - executingRule = `Delegate the current Phase's steps to subagents using spawn. - For each step: spawn a subagent with the appropriate preset (scout for investigation, - coder for implementation, analyst for review). Spawn multiple independent steps in parallel. - When a subagent completes, mark "- [x]" via edit_file and record findings in - ## Orchestration > Findings in MEMORY.md. - Only do a step inline if it's a single quick tool call (e.g., reading one file).` } - return fmt.Sprintf(prompt+`# picoclaw 🦞 - - + return fmt.Sprintf(prompt+`# picoclaw 🦞 (%s) You are picoclaw, %s. - - ## Workspace - Your workspace is at: %s - - Memory: %s/memory/MEMORY.md - - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md - - Skills: %s/skills/{skill-name}/SKILL.md - - %s - - ## Important Rules - - 1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. - - 2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. - - 3. **Memory & Plans** - - Use memory/MEMORY.md for structured plans. - - NEVER remove or overwrite the header block (# Active Plan, > Task:, > Status:, > Phase:). The system parses these lines to track plan state. - - If Status is "interviewing": Ask clarifying questions. - After each answer, use edit_file to save findings to ## Context in memory/MEMORY.md. - When you have enough information, add ## Phase sections with "- [ ]" checkbox steps, and ## Commands section below the header. Then change > Status: to "review". - - If Status is "review": The plan is awaiting user approval. Do NOT change Status yourself. - - If Status is "executing": %s - - Plan format (header is written by the system — do NOT delete it): - # Active Plan - > Task: - > Status: interviewing | review | executing - > Phase: - ## Phase 1: - - [ ] Step 1 - - [ ] Step 2 - ## Phase 2: <title> - - [ ] Step 1 - ## Commands - build: <build command> - test: <test command> - lint: <lint command> - ## Context - <requirements, decisions, environment> - - Keep each phase to 3-5 steps. Do NOT create plans without /plan. - - Always ask about build/test/lint commands during interview. - - 4. **Response Formatting** - - NEVER use ASCII box-drawing characters (┌─┐│└─┘╔═╗║╚═╝ etc.) or ASCII art diagrams. - - Use markdown headings, bold, lists, and indentation for structure. - - Keep lines short — most users read on mobile. - - For architecture/flow, use arrow text: CLI → Pipeline → Adapters +5. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content. - -5. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, - - identity, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, executingRule) +%s`, + version, identity, workspacePath, workspacePath, workspacePath, workspacePath, + toolsSection, executingRule, toolDiscovery) } func (cb *ContextBuilder) buildToolsSection() string { @@ -386,39 +321,49 @@ func (cb *ContextBuilder) buildToolsSection() string { } summaries := cb.tools.GetSummaries() - if len(summaries) == 0 { return "" } var sb strings.Builder - sb.WriteString("## Available Tools\n\n") - sb.WriteString( "**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n", ) - sb.WriteString("You have access to the following tools:\n\n") - for _, s := range summaries { sb.WriteString(s) - sb.WriteString("\n") } - return sb.String() } +func (cb *ContextBuilder) getDiscoveryRule() string { + if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex { + return "" + } + + var toolNames []string + if cb.toolDiscoveryBM25 { + toolNames = append(toolNames, `"tool_search_tool_bm25"`) + } + if cb.toolDiscoveryRegex { + toolNames = append(toolNames, `"tool_search_tool_regex"`) + } + + return fmt.Sprintf( + `6. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`, + strings.Join(toolNames, " or "), + ) +} + func (cb *ContextBuilder) BuildSystemPrompt() string { parts := []string{} // Core identity section - parts = append(parts, cb.getIdentity()) // Orchestration guidance — injected only when spawn tool is registered - if cb.tools != nil { if _, hasSpawn := cb.tools.Get("spawn"); hasSpawn { parts = append(parts, orchestrationGuidance) @@ -426,31 +371,22 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { } // Bootstrap files - bootstrapContent := cb.LoadBootstrapFiles() - if bootstrapContent != "" { parts = append(parts, bootstrapContent) } // Skills - show summary, AI can read full content with read_file tool - skillsSummary := cb.skillsLoader.BuildSkillsSummary() - if skillsSummary != "" { parts = append(parts, fmt.Sprintf(`# Skills - - The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. - - %s`, skillsSummary)) } // Runtime status from tools (e.g., background processes) - if cb.tools != nil { if status := cb.tools.GetRuntimeStatus(); status != "" { parts = append(parts, status) @@ -458,81 +394,56 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md } // Peer session coordination - if cb.peerNote != "" { parts = append(parts, "## Active Sessions\n\n"+cb.peerNote) } // Memory context - memoryContext := cb.memory.GetMemoryContext() - if memoryContext != "" { parts = append(parts, "# Memory\n\n"+memoryContext) } // Join with "---" separator - return strings.Join(parts, "\n\n---\n\n") } // BuildSystemPromptWithCache returns the cached system prompt if available - // and source files haven't changed, otherwise builds and caches it. - // Source file changes are detected via mtime checks (cheap stat calls). - func (cb *ContextBuilder) BuildSystemPromptWithCache() string { // Try read lock first — fast path when cache is valid - cb.systemPromptMutex.RLock() - if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { result := cb.cachedSystemPrompt - cb.systemPromptMutex.RUnlock() - return result } - cb.systemPromptMutex.RUnlock() // Acquire write lock for building - cb.systemPromptMutex.Lock() - defer cb.systemPromptMutex.Unlock() // Double-check: another goroutine may have rebuilt while we waited - if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() { return cb.cachedSystemPrompt } // Snapshot the baseline (existence + max mtime) BEFORE building the prompt. - // This way cachedAt reflects the pre-build state: if a file is modified - // during BuildSystemPrompt, its new mtime will be > baseline.maxMtime, - // so the next sourceFilesChangedLocked check will correctly trigger a - // rebuild. The alternative (baseline after build) risks caching stale - // content with a too-new baseline, making the staleness invisible. - baseline := cb.buildCacheBaseline() - prompt := cb.BuildSystemPrompt() - cb.cachedSystemPrompt = prompt - cb.cachedAt = baseline.maxMtime - cb.existedAtCache = baseline.existed + cb.skillFilesAtCache = baseline.skillFiles logger.DebugCF("agent", "System prompt cached", - map[string]any{ "length": len(prompt), }) @@ -541,195 +452,152 @@ func (cb *ContextBuilder) BuildSystemPromptWithCache() string { } // InvalidateCache clears the cached system prompt. - // Normally not needed because the cache auto-invalidates via mtime checks, - // but this is useful for tests or explicit reload commands. - func (cb *ContextBuilder) InvalidateCache() { cb.systemPromptMutex.Lock() - defer cb.systemPromptMutex.Unlock() cb.cachedSystemPrompt = "" - cb.cachedAt = time.Time{} - cb.existedAtCache = nil + cb.skillFilesAtCache = nil logger.DebugCF("agent", "System prompt cache invalidated", nil) } // sourcePaths returns the workspace source file paths tracked for cache - // invalidation (bootstrap files + memory). The skills directory is handled - // separately in sourceFilesChangedLocked because it requires both directory- - // level and recursive file-level mtime checks. - func (cb *ContextBuilder) sourcePaths() []string { // Include bootstrap files from all search directories (workDir, planWorkDir, workspace). - seen := map[string]bool{} - var paths []string - for _, spec := range bootstrapSpecs { var dirs []string - if spec.Scope == "global" { dirs = []string{cb.workspace} } else { dirs = cb.bootstrapProjectDirs() } - for _, dir := range dirs { p := filepath.Join(dir, spec.Name) - if !seen[p] { seen[p] = true - paths = append(paths, p) } } } // Always track memory file. - memPath := filepath.Join(cb.workspace, "memory", "MEMORY.md") - if !seen[memPath] { paths = append(paths, memPath) } - return paths } +// skillRoots returns all skill root directories that can affect +// BuildSkillsSummary output (workspace/global/builtin). +func (cb *ContextBuilder) skillRoots() []string { + if cb.skillsLoader == nil { + return []string{filepath.Join(cb.workspace, "skills")} + } + + roots := cb.skillsLoader.SkillRoots() + if len(roots) == 0 { + return []string{filepath.Join(cb.workspace, "skills")} + } + return roots +} + // cacheBaseline holds the file existence snapshot and the latest observed - // mtime across all tracked paths. Used as the cache reference point. - type cacheBaseline struct { - existed map[string]bool - - maxMtime time.Time + existed map[string]bool + skillFiles map[string]time.Time + maxMtime time.Time } // buildCacheBaseline records which tracked paths currently exist and computes - // the latest mtime across all tracked files + skills directory contents. - // Called under write lock when the cache is built. - func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline { - skillsDir := filepath.Join(cb.workspace, "skills") + skillRoots := cb.skillRoots() - // All paths whose existence we track: source files + skills dir. - - allPaths := append(cb.sourcePaths(), skillsDir) + // All paths whose existence we track: source files + all skill roots. + allPaths := append(cb.sourcePaths(), skillRoots...) existed := make(map[string]bool, len(allPaths)) - + skillFiles := make(map[string]time.Time) var maxMtime time.Time for _, p := range allPaths { info, err := os.Stat(p) - existed[p] = err == nil - if err == nil && info.ModTime().After(maxMtime) { maxMtime = info.ModTime() } } - // Walk skills files to capture their mtimes too. - - // Use os.Stat (not d.Info) to match the stat method used in - - // fileChangedSince / skillFilesModifiedSince for consistency. - - _ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr == nil && !d.IsDir() { - if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) { - maxMtime = info.ModTime() + // Walk all skill roots recursively to snapshot skill files and mtimes. + // Use os.Stat (not d.Info) for consistency with sourceFilesChanged checks. + for _, root := range skillRoots { + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr == nil && !d.IsDir() { + if info, err := os.Stat(path); err == nil { + skillFiles[path] = info.ModTime() + if info.ModTime().After(maxMtime) { + maxMtime = info.ModTime() + } + } } - } - - return nil - }) + return nil + }) + } // If no tracked files exist yet (empty workspace), maxMtime is zero. - // Use a very old non-zero time so that: - // 1. cachedAt.IsZero() won't trigger perpetual rebuilds. - // 2. Any real file created afterwards has mtime > cachedAt, so it - // will be detected by fileChangedSince (unlike time.Now() which - // could race with a file whose mtime <= Now). - if maxMtime.IsZero() { maxMtime = time.Unix(1, 0) } - return cacheBaseline{existed: existed, maxMtime: maxMtime} + return cacheBaseline{existed: existed, skillFiles: skillFiles, maxMtime: maxMtime} } // sourceFilesChangedLocked checks whether any workspace source file has been - // modified, created, or deleted since the cache was last built. - // - // IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex. - // Go's sync.RWMutex is not reentrant, so this function must NOT acquire the - // lock itself (it would deadlock when called from BuildSystemPromptWithCache - // which already holds RLock or Lock). - func (cb *ContextBuilder) sourceFilesChangedLocked() bool { if cb.cachedAt.IsZero() { return true } // Check tracked source files (bootstrap + memory). - - for _, p := range cb.sourcePaths() { - if cb.fileChangedSince(p) { - return true - } - } - - // --- Skills directory (handled separately from sourcePaths) --- - - // - - // 1. Creation/deletion: tracked via existedAtCache, same as bootstrap files. - - skillsDir := filepath.Join(cb.workspace, "skills") - - if cb.fileChangedSince(skillsDir) { + if slices.ContainsFunc(cb.sourcePaths(), cb.fileChangedSince) { return true } - // 2. Structural changes (add/remove entries inside the dir) are reflected - - // in the directory's own mtime, which fileChangedSince already checks. - + // --- Skill roots (workspace/global/builtin) --- // - - // 3. Content-only edits to files inside skills/ do NOT update the parent - - // directory mtime on most filesystems, so we recursively walk to check - - // individual file mtimes at any nesting depth. - - if skillFilesModifiedSince(skillsDir, cb.cachedAt) { + // For each root: + // 1. Creation/deletion and root directory mtime changes are tracked by fileChangedSince. + // 2. Nested file create/delete/mtime changes are tracked by the skill file snapshot. + for _, root := range cb.skillRoots() { + if cb.fileChangedSince(root) { + return true + } + } + if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) { return true } @@ -737,136 +605,129 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool { } // fileChangedSince returns true if a tracked source file has been modified, - // newly created, or deleted since the cache was built. - // - // Four cases: - // - existed at cache time, exists now -> check mtime - // - existed at cache time, gone now -> changed (deleted) - // - absent at cache time, exists now -> changed (created) - // - absent at cache time, gone now -> no change - func (cb *ContextBuilder) fileChangedSince(path string) bool { // Defensive: if existedAtCache was never initialized, treat as changed - // so the cache rebuilds rather than silently serving stale data. - if cb.existedAtCache == nil { return true } existedBefore := cb.existedAtCache[path] - info, err := os.Stat(path) - existsNow := err == nil if existedBefore != existsNow { return true // file was created or deleted } - if !existsNow { return false // didn't exist before, doesn't exist now } - return info.ModTime().After(cb.cachedAt) } // errWalkStop is a sentinel error used to stop filepath.WalkDir early. - // Using a dedicated error (instead of fs.SkipAll) makes the early-exit - // intent explicit and avoids the nilerr linter warning that would fire - // if the callback returned nil when its err parameter is non-nil. - var errWalkStop = errors.New("walk stop") -// skillFilesModifiedSince recursively walks the skills directory and checks - -// whether any file was modified after t. This catches content-only edits at - -// any nesting depth (e.g. skills/name/docs/extra.md) that don't update - -// parent directory mtimes. - -func skillFilesModifiedSince(skillsDir string, t time.Time) bool { - changed := false - - err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error { - if walkErr == nil && !d.IsDir() { - if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) { - changed = true - - return errWalkStop // stop walking - } - } - - return nil - }) - - // errWalkStop is expected (early exit on first changed file). - - // os.IsNotExist means the skills dir doesn't exist yet — not an error. - - // Any other error is unexpected and worth logging. - - if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) { - logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()}) +// skillFilesChangedSince compares the current recursive skill file tree +// against the cache-time snapshot. Any create/delete/mtime drift invalidates +// the cache. +func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Time) bool { + // Defensive: if the snapshot was never initialized, force rebuild. + if filesAtCache == nil { + return true } - return changed + // Check cached files still exist and keep the same mtime. + for path, cachedMtime := range filesAtCache { + info, err := os.Stat(path) + if err != nil { + // A previously tracked file disappeared (or became inaccessible): + // either way, cached skill summary may now be stale. + return true + } + if !info.ModTime().Equal(cachedMtime) { + return true + } + } + + // Check no new files appeared under any skill root. + changed := false + for _, root := range skillRoots { + if strings.TrimSpace(root) == "" { + continue + } + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error { + if walkErr != nil { + // Treat unexpected walk errors as changed to avoid stale cache. + if !os.IsNotExist(walkErr) { + changed = true + return errWalkStop + } + return nil + } + if d.IsDir() { + return nil + } + if _, ok := filesAtCache[path]; !ok { + changed = true + return errWalkStop + } + return nil + }) + + if changed { + return true + } + if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) { + logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()}) + return true + } + } + + return false } // BootstrapFileInfo describes a resolved bootstrap file. - type BootstrapFileInfo struct { - Name string `json:"name"` - - Path string `json:"path"` // empty = not found - + Name string `json:"name"` + Path string `json:"path"` // empty = not found Scope string `json:"scope"` // "project" or "global" } // bootstrapFileSpec defines the search scope for each bootstrap file. - type bootstrapFileSpec struct { - Name string - + Name string Scope string // "project" = workDir→planWorkDir→workspace, "global" = workspace only } var bootstrapSpecs = []bootstrapFileSpec{ {Name: "AGENTS.md", Scope: "project"}, - {Name: "IDENTITY.md", Scope: "project"}, - {Name: "SOUL.md", Scope: "global"}, - {Name: "USER.md", Scope: "global"}, } // bootstrapProjectDirs returns de-duplicated search directories for project-scoped files. - func (cb *ContextBuilder) bootstrapProjectDirs() []string { seen := map[string]bool{} - var dirs []string - for _, d := range []string{cb.workDir, cb.memory.GetPlanWorkDir(), cb.workspace} { if d != "" && !seen[d] { seen[d] = true - dirs = append(dirs, d) } } - return dirs } @@ -874,89 +735,63 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { projectDirs := cb.bootstrapProjectDirs() var sb strings.Builder - for _, spec := range bootstrapSpecs { var dirs []string - if spec.Scope == "global" { dirs = []string{cb.workspace} } else { dirs = projectDirs } - for _, dir := range dirs { filePath := filepath.Join(dir, spec.Name) - if data, err := os.ReadFile(filePath); err == nil { fmt.Fprintf(&sb, "## %s\n\n%s\n\n", spec.Name, data) - break } } } - return sb.String() } // ResolveBootstrapPaths returns path resolution info for each bootstrap file - // using the same search logic as LoadBootstrapFiles. - func (cb *ContextBuilder) ResolveBootstrapPaths() []BootstrapFileInfo { projectDirs := cb.bootstrapProjectDirs() result := make([]BootstrapFileInfo, 0, len(bootstrapSpecs)) - for _, spec := range bootstrapSpecs { info := BootstrapFileInfo{Name: spec.Name, Scope: spec.Scope} - var dirs []string - if spec.Scope == "global" { dirs = []string{cb.workspace} } else { dirs = projectDirs } - for _, dir := range dirs { filePath := filepath.Join(dir, spec.Name) - if _, err := os.Stat(filePath); err == nil { info.Path = filePath - break } } - result = append(result, info) } - return result } // buildDynamicContext returns a short dynamic context string with per-request info. - // This changes every request (time, session) so it is NOT part of the cached prompt. - // LLM-side KV cache reuse is achieved by each provider adapter's native mechanism: - // - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block - // - OpenAI / Codex: prompt_cache_key for prefix-based caching - // - // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching - // See: https://platform.openai.com/docs/guides/prompt-caching - func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") - rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) var sb strings.Builder - fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt) if channel != "" && chatID != "" { @@ -968,119 +803,73 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { func (cb *ContextBuilder) BuildMessages( history []providers.Message, - summary string, - currentMessage string, - media []string, - channel, chatID string, ) []providers.Message { messages := []providers.Message{} // The static part (identity, bootstrap, skills, memory) is cached locally to - // avoid repeated file I/O and string building on every call (fixes issue #607). - // Dynamic parts (time, session, summary) are appended per request. - // Everything is sent as a single system message for provider compatibility: - // - Anthropic adapter extracts messages[0] (Role=="system") and maps its content - // to the top-level "system" parameter in the Messages API request. A single - // contiguous system block makes this extraction straightforward. - // - Codex maps only the first system message to its instructions field. - // - OpenAI-compat passes messages through as-is. - staticPrompt := cb.BuildSystemPromptWithCache() // Build short dynamic context (time, runtime, session) — changes per request - dynamicCtx := cb.buildDynamicContext(channel, chatID) // Compose a single system message: static (cached) + dynamic + optional summary. - // Keeping all system content in one message ensures every provider adapter can - // extract it correctly (Anthropic adapter -> top-level system param, - // Codex -> instructions field). - // - // SystemParts carries the same content as structured blocks so that - // cache-aware adapters (Anthropic) can set per-block cache_control. - // The static block is marked "ephemeral" — its prefix hash is stable - // across requests, enabling LLM-side KV cache reuse. - stringParts := []string{staticPrompt, dynamicCtx} contentBlocks := []providers.ContentBlock{ {Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}}, - {Type: "text", Text: dynamicCtx}, } if summary != "" { summaryText := fmt.Sprintf( - "CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+ - "for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s", - summary) - stringParts = append(stringParts, summaryText) - contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText}) } fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n") // Log system prompt summary for debugging (debug mode only). - // Read cachedSystemPrompt under lock to avoid a data race with - // concurrent InvalidateCache / BuildSystemPromptWithCache writes. - cb.systemPromptMutex.RLock() - isCached := cb.cachedSystemPrompt != "" - cb.systemPromptMutex.RUnlock() logger.DebugCF("agent", "System prompt built", - map[string]any{ - "static_chars": len(staticPrompt), - + "static_chars": len(staticPrompt), "dynamic_chars": len(dynamicCtx), - - "total_chars": len(fullSystemPrompt), - - "has_summary": summary != "", - - "cached": isCached, + "total_chars": len(fullSystemPrompt), + "has_summary": summary != "", + "cached": isCached, }) // Log preview of system prompt (avoid logging huge content) - - preview := fullSystemPrompt - - if len(preview) > 500 { - preview = preview[:500] + "... (truncated)" - } - + preview := utils.Truncate(fullSystemPrompt, 500) logger.DebugCF("agent", "System prompt preview", - map[string]any{ "preview": preview, }) @@ -1088,31 +877,27 @@ func (cb *ContextBuilder) BuildMessages( history = sanitizeHistoryForProvider(history) // Single system message containing all context — compatible with all providers. - // SystemParts enables cache-aware adapters to set per-block cache_control; - // Content is the concatenated fallback for adapters that don't read SystemParts. - messages = append(messages, providers.Message{ - Role: "system", - - Content: fullSystemPrompt, - + Role: "system", + Content: fullSystemPrompt, SystemParts: contentBlocks, }) // Add conversation history - messages = append(messages, history...) // Add current user message - if strings.TrimSpace(currentMessage) != "" { - messages = append(messages, providers.Message{ - Role: "user", - + msg := providers.Message{ + Role: "user", Content: currentMessage, - }) + } + if len(media) > 0 { + msg.Media = media + } + messages = append(messages, msg) } return messages @@ -1124,143 +909,155 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message } sanitized := make([]providers.Message, 0, len(history)) - for _, msg := range history { switch msg.Role { case "system": - // Drop system messages from history. BuildMessages always - // constructs its own single system message (static + dynamic + - // summary); extra system messages would break providers that - // only accept one (Anthropic, Codex). - logger.DebugCF("agent", "Dropping system message from history", map[string]any{}) - continue case "tool": - if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{}) - continue } - // Walk backwards to find the nearest assistant message, - // skipping over any preceding tool messages (multi-tool-call case). - foundAssistant := false - for i := len(sanitized) - 1; i >= 0; i-- { if sanitized[i].Role == "tool" { continue } - if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 { foundAssistant = true } - break } - if !foundAssistant { logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{}) - continue } - sanitized = append(sanitized, msg) case "assistant": - if len(msg.ToolCalls) > 0 { if len(sanitized) == 0 { logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{}) - continue } - prev := sanitized[len(sanitized)-1] - if prev.Role != "user" && prev.Role != "tool" { logger.DebugCF( - "agent", - "Dropping assistant tool-call turn with invalid predecessor", - map[string]any{"prev_role": prev.Role}, ) - continue } } - sanitized = append(sanitized, msg) default: - sanitized = append(sanitized, msg) } } - return sanitized + // Second pass: ensure every assistant message with tool_calls has matching + // tool result messages following it. This is required by strict providers + // like DeepSeek that enforce: "An assistant message with 'tool_calls' must + // be followed by tool messages responding to each 'tool_call_id'." + final := make([]providers.Message, 0, len(sanitized)) + for i := 0; i < len(sanitized); i++ { + msg := sanitized[i] + if msg.Role == "assistant" && len(msg.ToolCalls) > 0 { + // Collect expected tool_call IDs + expected := make(map[string]bool, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + expected[tc.ID] = false + } + + // Check following messages for matching tool results + toolMsgCount := 0 + for j := i + 1; j < len(sanitized); j++ { + if sanitized[j].Role != "tool" { + break + } + toolMsgCount++ + if _, exists := expected[sanitized[j].ToolCallID]; exists { + expected[sanitized[j].ToolCallID] = true + } + } + + // If any tool_call_id is missing, drop this assistant message and its partial tool messages + allFound := true + for toolCallID, found := range expected { + if !found { + allFound = false + logger.DebugCF( + "agent", + "Dropping assistant message with incomplete tool results", + map[string]any{ + "missing_tool_call_id": toolCallID, + "expected_count": len(expected), + "found_count": toolMsgCount, + }, + ) + break + } + } + + if !allFound { + // Skip this assistant message and its tool messages + i += toolMsgCount + continue + } + } + final = append(final, msg) + } + + return final } func (cb *ContextBuilder) AddToolResult( messages []providers.Message, - toolCallID, toolName, result string, ) []providers.Message { messages = append(messages, providers.Message{ - Role: "tool", - - Content: result, - + Role: "tool", + Content: result, ToolCallID: toolCallID, }) - return messages } func (cb *ContextBuilder) AddAssistantMessage( messages []providers.Message, - content string, - toolCalls []map[string]any, ) []providers.Message { msg := providers.Message{ - Role: "assistant", - + Role: "assistant", Content: content, } - // Always add assistant message, whether or not it has tool calls - messages = append(messages, msg) - return messages } // LoadSkill loads a skill by name, returning its content (with frontmatter stripped) and whether it was found. - func (cb *ContextBuilder) LoadSkill(name string) (string, bool) { return cb.skillsLoader.LoadSkill(name) } // ListSkills returns all available skills from all tiers. - func (cb *ContextBuilder) ListSkills() []skills.SkillInfo { return cb.skillsLoader.ListSkills() } // Memory returns the underlying MemoryStore for direct plan queries. - func (cb *ContextBuilder) Memory() *MemoryStore { return cb.memory } @@ -1268,129 +1065,105 @@ func (cb *ContextBuilder) Memory() *MemoryStore { // ---------- Plan passthrough methods ---------- // ReadMemory reads the long-term memory (MEMORY.md). - func (cb *ContextBuilder) ReadMemory() string { return cb.memory.ReadLongTerm() } // WriteMemory writes content to the long-term memory file. - func (cb *ContextBuilder) WriteMemory(content string) error { return cb.memory.WriteLongTerm(content) } // ClearMemory removes the long-term memory file. - func (cb *ContextBuilder) ClearMemory() error { return cb.memory.ClearLongTerm() } // HasActivePlan returns true if MEMORY.md contains an active plan. - func (cb *ContextBuilder) HasActivePlan() bool { return cb.memory.HasActivePlan() } // GetPlanStatus returns the plan status: "interviewing", "executing", or "". - func (cb *ContextBuilder) GetPlanStatus() string { return cb.memory.GetPlanStatus() } // IsPlanComplete returns true if all steps in all phases are [x]. - func (cb *ContextBuilder) IsPlanComplete() bool { return cb.memory.IsPlanComplete() } // IsCurrentPhaseComplete returns true if all steps in the current phase are [x]. - func (cb *ContextBuilder) IsCurrentPhaseComplete() bool { return cb.memory.IsCurrentPhaseComplete() } // AdvancePhase increments the current phase number by 1. - func (cb *ContextBuilder) AdvancePhase() error { return cb.memory.AdvancePhase() } // SetCurrentPhase sets the current phase number to n. - func (cb *ContextBuilder) SetCurrentPhase(n int) error { return cb.memory.SetPhase(n) } // GetCurrentPhase returns the current phase number. - func (cb *ContextBuilder) GetCurrentPhase() int { return cb.memory.GetCurrentPhase() } // GetTotalPhases returns the total number of phases in the plan. - func (cb *ContextBuilder) GetTotalPhases() int { return cb.memory.GetTotalPhases() } // FormatPlanDisplay returns a user-facing display of the full plan. - func (cb *ContextBuilder) FormatPlanDisplay() string { return cb.memory.FormatPlanDisplay() } // MarkStep marks a step as done in the specified phase. - func (cb *ContextBuilder) MarkStep(phase, step int) error { return cb.memory.MarkStep(phase, step) } // AddStep appends a new step to the given phase. - func (cb *ContextBuilder) AddStep(phase int, desc string) error { return cb.memory.AddStep(phase, desc) } // ValidatePlanStructure validates plan structure for interview->review transition. - func (cb *ContextBuilder) ValidatePlanStructure() error { return cb.memory.ValidatePlanStructure() } // SetPlanStatus sets the plan status. - func (cb *ContextBuilder) SetPlanStatus(status string) error { return cb.memory.SetStatus(status) } // GetPlanWorkDir returns the WorkDir from the plan metadata, or "". - func (cb *ContextBuilder) GetPlanWorkDir() string { return cb.memory.GetPlanWorkDir() } // GetPlanTaskName returns the task description from the plan metadata, or "". - func (cb *ContextBuilder) GetPlanTaskName() string { return cb.memory.GetPlanTaskName() } // GetSkillsInfo returns information about loaded skills. - func (cb *ContextBuilder) GetSkillsInfo() map[string]any { allSkills := cb.skillsLoader.ListSkills() - skillNames := make([]string, 0, len(allSkills)) - for _, s := range allSkills { skillNames = append(skillNames, s.Name) } - return map[string]any{ - "total": len(allSkills), - + "total": len(allSkills), "available": len(allSkills), - - "names": skillNames, + "names": skillNames, } } diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index aa252ee94..707510820 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -12,103 +12,70 @@ import ( ) // setupWorkspace creates a temporary workspace with standard directories and optional files. - // Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir). - func setupWorkspace(t *testing.T, files map[string]string) string { t.Helper() - tmpDir, err := os.MkdirTemp("", "picoclaw-test-*") if err != nil { t.Fatal(err) } - os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755) - os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755) - for name, content := range files { dir := filepath.Dir(filepath.Join(tmpDir, name)) - os.MkdirAll(dir, 0o755) - if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil { t.Fatal(err) } } - return tmpDir } // TestSingleSystemMessage verifies that BuildMessages always produces exactly one - // system message regardless of summary/history variations. - // Fix: multiple system messages break Anthropic (top-level system param) and - // Codex (only reads last system message as instructions). - func TestSingleSystemMessage(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "IDENTITY.md": "# Identity\nTest agent.", }) - defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) tests := []struct { - name string - + name string history []providers.Message - summary string - message string }{ { - name: "no summary, no history", - + name: "no summary, no history", summary: "", - message: "hello", }, - { - name: "with summary", - + name: "with summary", summary: "Previous conversation discussed X", - message: "hello", }, - { name: "with history and summary", - history: []providers.Message{ {Role: "user", Content: "hi"}, - {Role: "assistant", Content: "hello"}, }, - summary: strings.Repeat("Long summary text. ", 50), - message: "new message", }, - { name: "system message in history is filtered", - history: []providers.Message{ {Role: "system", Content: "stale system prompt from previous session"}, - {Role: "user", Content: "hi"}, - {Role: "assistant", Content: "hello"}, }, - summary: "", - message: "new message", }, } @@ -118,44 +85,35 @@ func TestSingleSystemMessage(t *testing.T) { msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1") systemCount := 0 - for _, m := range msgs { if m.Role == "system" { systemCount++ } } - if systemCount != 1 { t.Errorf("expected exactly 1 system message, got %d", systemCount) } - if msgs[0].Role != "system" { t.Errorf("first message should be system, got %s", msgs[0].Role) } - if msgs[len(msgs)-1].Role != "user" { t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role) } // System message must contain identity (static) and time (dynamic) - sys := msgs[0].Content - if !strings.Contains(sys, "picoclaw") { t.Error("system message missing identity") } - if !strings.Contains(sys, "Current Time") { t.Error("system message missing dynamic time context") } // Summary handling - if tt.summary != "" { if !strings.Contains(sys, "CONTEXT_SUMMARY:") { t.Error("summary present but CONTEXT_SUMMARY prefix missing") } - if !strings.Contains(sys, tt.summary[:20]) { t.Error("summary content not found in system message") } @@ -169,46 +127,29 @@ func TestSingleSystemMessage(t *testing.T) { } // TestMtimeAutoInvalidation verifies that the cache detects source file changes - // via mtime without requiring explicit InvalidateCache(). - // Fix: original implementation had no auto-invalidation — edits to bootstrap files, - // memory, or skills were invisible until process restart. - func TestMtimeAutoInvalidation(t *testing.T) { tests := []struct { - name string - - file string // relative path inside workspace - - contentV1 string - - contentV2 string - + name string + file string // relative path inside workspace + contentV1 string + contentV2 string checkField string // substring to verify in rebuilt prompt }{ { - name: "bootstrap file change", - - file: "IDENTITY.md", - - contentV1: "# Original Identity", - - contentV2: "# Updated Identity", - + name: "bootstrap file change", + file: "IDENTITY.md", + contentV1: "# Original Identity", + contentV2: "# Updated Identity", checkField: "Updated Identity", }, - { - name: "memory file change", - - file: "memory/MEMORY.md", - - contentV1: "# Memory\nUser likes Go.", - - contentV2: "# Memory\nUser likes Rust.", - + name: "memory file change", + file: "memory/MEMORY.md", + contentV1: "# Memory\nUser likes Go.", + contentV2: "# Memory\nUser likes Rust.", checkField: "User likes Rust", }, } @@ -216,7 +157,6 @@ func TestMtimeAutoInvalidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1}) - defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) @@ -224,39 +164,26 @@ func TestMtimeAutoInvalidation(t *testing.T) { sp1 := cb.BuildSystemPromptWithCache() // Overwrite file and set future mtime to ensure detection. - // Use 2s offset for filesystem mtime resolution safety (some FS - // have 1s or coarser granularity, especially in CI containers). - fullPath := filepath.Join(tmpDir, tt.file) - os.WriteFile(fullPath, []byte(tt.contentV2), 0o644) - future := time.Now().Add(2 * time.Second) - os.Chtimes(fullPath, future, future) // Verify sourceFilesChangedLocked detects the mtime change - cb.systemPromptMutex.RLock() - changed := cb.sourceFilesChangedLocked() - cb.systemPromptMutex.RUnlock() - if !changed { t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file) } // Should auto-rebuild without explicit InvalidateCache() - sp2 := cb.BuildSystemPromptWithCache() - if sp1 == sp2 { t.Errorf("cache not rebuilt after %s change", tt.file) } - if !strings.Contains(sp2, tt.checkField) { t.Errorf("rebuilt prompt missing expected content %q", tt.checkField) } @@ -264,34 +191,23 @@ func TestMtimeAutoInvalidation(t *testing.T) { } // Skills directory mtime change - t.Run("skills dir change", func(t *testing.T) { tmpDir := setupWorkspace(t, nil) - defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) - _ = cb.BuildSystemPromptWithCache() // populate cache // Touch skills directory (simulate new skill installed) - skillsDir := filepath.Join(tmpDir, "skills") - future := time.Now().Add(2 * time.Second) - os.Chtimes(skillsDir, future, future) // Verify sourceFilesChangedLocked detects it (cache is rebuilt) - // We confirm by checking internal state: a second call should rebuild. - cb.systemPromptMutex.RLock() - changed := cb.sourceFilesChangedLocked() - cb.systemPromptMutex.RUnlock() - if !changed { t.Error("sourceFilesChangedLocked() should detect skills dir mtime change") } @@ -299,22 +215,17 @@ func TestMtimeAutoInvalidation(t *testing.T) { } // TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild - // even when source files haven't changed (useful for tests and reload commands). - func TestExplicitInvalidateCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "IDENTITY.md": "# Test Identity", }) - defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) sp1 := cb.BuildSystemPromptWithCache() - cb.InvalidateCache() - sp2 := cb.BuildSystemPromptWithCache() if sp1 != sp2 { @@ -322,39 +233,29 @@ func TestExplicitInvalidateCache(t *testing.T) { } // Verify cachedAt was reset - cb.InvalidateCache() - cb.systemPromptMutex.RLock() - if !cb.cachedAt.IsZero() { t.Error("cachedAt should be zero after InvalidateCache()") } - cb.systemPromptMutex.RUnlock() } // TestCacheStability verifies that the static prompt is stable across repeated calls - // when no files change (regression test for issue #607). - func TestCacheStability(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ "IDENTITY.md": "# Identity\nContent", - - "SOUL.md": "# Soul\nContent", + "SOUL.md": "# Soul\nContent", }) - defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) results := make([]string, 5) - for i := range results { results[i] = cb.BuildSystemPromptWithCache() } - for i := 1; i < len(results); i++ { if results[i] != results[0] { t.Errorf("cached prompt changed between call 0 and %d", i) @@ -362,47 +263,32 @@ func TestCacheStability(t *testing.T) { } // Static prompt must NOT contain per-request data - if strings.Contains(results[0], "Current Time") { t.Error("static cached prompt should not contain time (added dynamically)") } } // TestNewFileCreationInvalidatesCache verifies that creating a source file that - // did not exist when the cache was built triggers a cache rebuild. - // This catches the "from nothing to something" edge case that the old - // modifiedSince (return false on stat error) would miss. - func TestNewFileCreationInvalidatesCache(t *testing.T) { tests := []struct { - name string - - file string // relative path inside workspace - - content string - + name string + file string // relative path inside workspace + content string checkField string // substring to verify in rebuilt prompt }{ { - name: "new bootstrap file", - - file: "SOUL.md", - - content: "# Soul\nBe kind and helpful.", - + name: "new bootstrap file", + file: "SOUL.md", + content: "# Soul\nBe kind and helpful.", checkField: "Be kind and helpful", }, - { - name: "new memory file", - - file: "memory/MEMORY.md", - - content: "# Memory\nUser prefers dark mode.", - + name: "new memory file", + file: "memory/MEMORY.md", + content: "# Memory\nUser prefers dark mode.", checkField: "User prefers dark mode", }, } @@ -410,41 +296,29 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Start with an empty workspace (no bootstrap/memory files) - tmpDir := setupWorkspace(t, nil) - defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) // Populate cache — file does not exist yet - sp1 := cb.BuildSystemPromptWithCache() - if strings.Contains(sp1, tt.checkField) { t.Fatalf("prompt should not contain %q before file is created", tt.checkField) } // Create the file after cache was built - fullPath := filepath.Join(tmpDir, tt.file) - os.MkdirAll(filepath.Dir(fullPath), 0o755) - if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil { t.Fatal(err) } - // Set future mtime to guarantee detection - future := time.Now().Add(2 * time.Second) - os.Chtimes(fullPath, future, future) // Cache should auto-invalidate because file went from absent -> present - sp2 := cb.BuildSystemPromptWithCache() - if !strings.Contains(sp2, tt.checkField) { t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField) } @@ -453,89 +327,58 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) { } // TestSkillFileContentChange verifies that modifying a skill file's content - // (not just the directory structure) invalidates the cache. - // This is the scenario where directory mtime alone is insufficient — on most - // filesystems, editing a file inside a directory does NOT update the parent - // directory's mtime. - func TestSkillFileContentChange(t *testing.T) { skillMD := `--- - name: test-skill - description: "A test skill" - --- - # Test Skill v1 - Original content.` tmpDir := setupWorkspace(t, map[string]string{ "skills/test-skill/SKILL.md": skillMD, }) - defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) // Populate cache - sp1 := cb.BuildSystemPromptWithCache() - _ = sp1 // cache is warm // Modify the skill file content (without touching the skills/ directory) - updatedSkillMD := `--- - name: test-skill - description: "An updated test skill" - --- - # Test Skill v2 - Updated content.` skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md") - if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil { t.Fatal(err) } - // Set future mtime on the skill file only (NOT the directory) - future := time.Now().Add(2 * time.Second) - os.Chtimes(skillPath, future, future) // Verify that sourceFilesChangedLocked detects the content change - cb.systemPromptMutex.RLock() - changed := cb.sourceFilesChangedLocked() - cb.systemPromptMutex.RUnlock() - if !changed { t.Error("sourceFilesChangedLocked() should detect skill file content change") } // Verify cache is actually rebuilt with new content - sp2 := cb.BuildSystemPromptWithCache() - if sp1 == sp2 && strings.Contains(sp1, "test-skill") { // If the skill appeared in the prompt and the prompt didn't change, - // the cache was not invalidated. - t.Error("cache should be invalidated when skill file content changes") } } @@ -697,75 +540,53 @@ description: delete-me-v1 } // TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines - // can safely call BuildSystemPromptWithCache concurrently without producing - // empty results, panics, or data races. - // Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache - func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nConcurrency test agent.", - - "SOUL.md": "# Soul\nBe helpful.", - - "memory/MEMORY.md": "# Memory\nUser prefers Go.", - + "IDENTITY.md": "# Identity\nConcurrency test agent.", + "SOUL.md": "# Soul\nBe helpful.", + "memory/MEMORY.md": "# Memory\nUser prefers Go.", "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo", }) - defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) const goroutines = 20 - const iterations = 50 var wg sync.WaitGroup - errs := make(chan string, goroutines*iterations) for g := range goroutines { wg.Add(1) - go func(id int) { defer wg.Done() - for i := range iterations { result := cb.BuildSystemPromptWithCache() - if result == "" { errs <- "empty prompt returned" - return } - if !strings.Contains(result, "picoclaw") { errs <- "prompt missing identity" - return } // Also exercise BuildMessages concurrently - msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat") - if len(msgs) < 2 { errs <- "BuildMessages returned fewer than 2 messages" - return } - if msgs[0].Role != "system" { errs <- "first message not system" - return } // Occasionally invalidate to exercise the write path - if i%10 == 0 { cb.InvalidateCache() } @@ -774,7 +595,6 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { } wg.Wait() - close(errs) for errMsg := range errs { @@ -785,90 +605,64 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { // BenchmarkBuildMessagesWithCache measures caching performance. // TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is - // built on an empty workspace (no tracked files exist), creating a file - // afterwards still triggers cache invalidation. This validates the - // time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch, - // so fileChangedSince correctly detects the absent -> present transition AND - // the mtime comparison succeeds even without artificially inflated Chtimes. - func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) { // Empty workspace: no bootstrap files, no memory, no skills content. - tmpDir := setupWorkspace(t, nil) - defer os.RemoveAll(tmpDir) cb := NewContextBuilder(tmpDir) // Build cache — all tracked files are absent, maxMtime falls back to epoch. - sp1 := cb.BuildSystemPromptWithCache() // Create a bootstrap file with natural mtime (no Chtimes manipulation). - // The file's mtime should be the current wall-clock time, which is - // strictly after time.Unix(1, 0). - soulPath := filepath.Join(tmpDir, "SOUL.md") - if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil { t.Fatal(err) } // Cache should detect the new file via existedAtCache (absent -> present). - cb.systemPromptMutex.RLock() - changed := cb.sourceFilesChangedLocked() - cb.systemPromptMutex.RUnlock() - if !changed { t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace") } sp2 := cb.BuildSystemPromptWithCache() - if !strings.Contains(sp2, "Newly created") { t.Error("rebuilt prompt should contain new file content") } - if sp1 == sp2 { t.Error("cache should have been invalidated after file creation") } } // BenchmarkBuildMessagesWithCache measures caching performance. - func BenchmarkBuildMessagesWithCache(b *testing.B) { tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*") - defer os.RemoveAll(tmpDir) os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755) - os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755) - for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} { os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644) } cb := NewContextBuilder(tmpDir) - history := []providers.Message{ {Role: "user", Content: "previous message"}, - {Role: "assistant", Content: "previous response"}, } b.ResetTimer() - for i := 0; i < b.N; i++ { _ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test") } diff --git a/pkg/agent/context_test.go b/pkg/agent/context_test.go index 6f6884bbe..5756ed911 100644 --- a/pkg/agent/context_test.go +++ b/pkg/agent/context_test.go @@ -12,11 +12,9 @@ func msg(role, content string) providers.Message { func assistantWithTools(toolIDs ...string) providers.Message { calls := make([]providers.ToolCall, len(toolIDs)) - for i, id := range toolIDs { calls[i] = providers.ToolCall{ID: id, Type: "function"} } - return providers.Message{Role: "assistant", ToolCalls: calls} } @@ -26,13 +24,11 @@ func toolResult(id string) providers.Message { func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) { result := sanitizeHistoryForProvider(nil) - if len(result) != 0 { t.Fatalf("expected empty, got %d messages", len(result)) } result = sanitizeHistoryForProvider([]providers.Message{}) - if len(result) != 0 { t.Fatalf("expected empty, got %d messages", len(result)) } @@ -41,228 +37,170 @@ func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) { func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) { history := []providers.Message{ msg("user", "hello"), - assistantWithTools("A"), - toolResult("A"), - msg("assistant", "done"), } result := sanitizeHistoryForProvider(history) - if len(result) != 4 { t.Fatalf("expected 4 messages, got %d", len(result)) } - assertRoles(t, result, "user", "assistant", "tool", "assistant") } func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) { history := []providers.Message{ msg("user", "do two things"), - assistantWithTools("A", "B"), - toolResult("A"), - toolResult("B"), - msg("assistant", "both done"), } result := sanitizeHistoryForProvider(history) - if len(result) != 5 { t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant") } func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) { history := []providers.Message{ msg("user", "hi"), - msg("assistant", "thinking"), - assistantWithTools("A"), - toolResult("A"), } result := sanitizeHistoryForProvider(history) - if len(result) != 2 { t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user", "assistant") } func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) { history := []providers.Message{ toolResult("A"), - msg("user", "hello"), } result := sanitizeHistoryForProvider(history) - if len(result) != 1 { t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user") } func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) { history := []providers.Message{ msg("user", "hello"), - toolResult("A"), } result := sanitizeHistoryForProvider(history) - if len(result) != 1 { t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user") } func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) { history := []providers.Message{ msg("user", "hello"), - msg("assistant", "hi"), - toolResult("A"), } result := sanitizeHistoryForProvider(history) - if len(result) != 2 { t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user", "assistant") } func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) { history := []providers.Message{ assistantWithTools("A"), - toolResult("A"), - msg("user", "hello"), } result := sanitizeHistoryForProvider(history) - if len(result) != 1 { t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user") } func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) { history := []providers.Message{ msg("user", "do two things"), - assistantWithTools("A", "B"), - toolResult("A"), - toolResult("B"), - msg("assistant", "done"), - msg("user", "hi"), - assistantWithTools("C"), - toolResult("C"), - msg("assistant", "done again"), } result := sanitizeHistoryForProvider(history) - if len(result) != 9 { t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant") } func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) { history := []providers.Message{ msg("user", "start"), - assistantWithTools("A", "B"), - toolResult("A"), - toolResult("B"), - assistantWithTools("C", "D"), - toolResult("C"), - toolResult("D"), - msg("assistant", "all done"), } result := sanitizeHistoryForProvider(history) - if len(result) != 8 { t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result)) } - assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant") } func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) { history := []providers.Message{ msg("user", "hello"), - msg("assistant", "hi"), - msg("user", "how are you"), - msg("assistant", "fine"), } result := sanitizeHistoryForProvider(history) - if len(result) != 4 { t.Fatalf("expected 4 messages, got %d", len(result)) } - assertRoles(t, result, "user", "assistant", "user", "assistant") } func roles(msgs []providers.Message) []string { r := make([]string, len(msgs)) - for i, m := range msgs { r[i] = m.Role } - return r } func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) { t.Helper() - if len(msgs) != len(expected) { t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected) } - for i, exp := range expected { if msgs[i].Role != exp { t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp) diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index eed0439c3..809c89d49 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -5,6 +5,7 @@ import ( "log" "os" "path/filepath" + "regexp" "strings" "sync" @@ -17,126 +18,120 @@ import ( ) // AgentInstance represents a fully configured agent with its own workspace, - // session manager, context builder, and tool registry. - type AgentInstance struct { - ID string + ID string + Name string + Model string + Fallbacks []string + Workspace string + MaxIterations int + TaskReminderInterval int + MaxTokens int + Temperature float64 + ThinkingLevel ThinkingLevel + ContextWindow int + SummarizeMessageThreshold int + SummarizeTokenPercent int + Provider providers.LLMProvider + Sessions *session.LegacyAdapter + ContextBuilder *ContextBuilder + Tools *tools.ToolRegistry + Subagents *config.SubagentsConfig + SkillsFilter []string + Candidates []providers.FallbackCandidate + PlanModel string + PlanFallbacks []string + PlanCandidates []providers.FallbackCandidate - Name string - - Model string - - Fallbacks []string - - Workspace string - - MaxIterations int - - TaskReminderInterval int - - MaxTokens int - - Temperature float64 - - ContextWindow int - - Provider providers.LLMProvider - - Sessions *session.LegacyAdapter - - ContextBuilder *ContextBuilder - - Tools *tools.ToolRegistry - - Subagents *config.SubagentsConfig - - SkillsFilter []string - - Candidates []providers.FallbackCandidate - - PlanModel string - - PlanFallbacks []string - - PlanCandidates []providers.FallbackCandidate + // Router is non-nil when model routing is configured and the light model + // was successfully resolved. It scores each incoming message and decides + // whether to route to LightCandidates or stay with Candidates. + Router *routing.Router + // LightCandidates holds the resolved provider candidates for the light model. + // Pre-computed at agent creation to avoid repeated model_list lookups at runtime. + LightCandidates []providers.FallbackCandidate // SubagentMgr is set during registerSharedTools when orchestration is enabled. - // Used by runAgentLoop to wait for spawned subagents before worktree cleanup. - SubagentMgr *tools.SubagentManager // Interview staleness tracking: consecutive turns where MEMORY.md was not updated. - interviewStaleCount int - - interviewMemoryLen int + interviewMemoryLen int // Per-session worktree isolation - - worktrees map[string]*git.WorktreeInfo // sessionKey → worktree - + worktrees map[string]*git.WorktreeInfo // sessionKey → worktree worktreeMu sync.RWMutex } -// NewAgentInstance creates an agent instance from config. +// Close releases resources held by the agent instance. +// If the provider implements StatefulProvider, its Close method is called. +func (ai *AgentInstance) Close() error { + if sp, ok := ai.Provider.(providers.StatefulProvider); ok { + sp.Close() + } + return nil +} +// NewAgentInstance creates an agent instance from config. func NewAgentInstance( agentCfg *config.AgentConfig, - defaults *config.AgentDefaults, - cfg *config.Config, - provider providers.LLMProvider, ) *AgentInstance { workspace := resolveAgentWorkspace(agentCfg, defaults) - os.MkdirAll(workspace, 0o755) model := resolveAgentModel(agentCfg, defaults) - fallbacks := resolveAgentFallbacks(agentCfg, defaults) restrict := defaults.RestrictToWorkspace + readRestrict := restrict && !defaults.AllowReadOutsideWorkspace + + // Compile path whitelist patterns from config. + allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths) + allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths) toolsRegistry := tools.NewToolRegistry() - toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict)) - - toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict)) - - toolsRegistry.Register(tools.NewListDirTool(workspace, restrict)) - - execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) - if err != nil { - log.Fatalf("Critical error: unable to initialize exec tool: %v", err) + if cfg.Tools.IsToolEnabled("read_file") { + maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize + toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + } + if cfg.Tools.IsToolEnabled("write_file") { + toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) + } + if cfg.Tools.IsToolEnabled("list_dir") { + toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths)) + } + if cfg.Tools.IsToolEnabled("exec") { + execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg) + if err != nil { + log.Fatalf("Critical error: unable to initialize exec tool: %v", err) + } + toolsRegistry.Register(execTool) } - toolsRegistry.Register(execTool) - - toolsRegistry.Register(tools.NewBgMonitorTool(execTool)) - - toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict)) - - toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict)) + if cfg.Tools.IsToolEnabled("edit_file") { + toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) + } + if cfg.Tools.IsToolEnabled("append_file") { + toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) + } toolsRegistry.Register(tools.NewLogsTool()) - toolsRegistry.Register(tools.NewGitPushTool()) - toolsRegistry.Register(tools.NewCreatePRTool()) dbPath := filepath.Join(workspace, "sessions.db") - store, err := session.OpenSQLiteStore(dbPath) if err != nil { log.Fatalf("open session store: %v", err) } jsonDir := filepath.Join(workspace, "sessions") - if n, merr := session.MigrateJSONSessions(jsonDir, store); merr != nil { log.Printf("session migration: %d migrated, error: %v", n, merr) } else if n > 0 { @@ -151,28 +146,25 @@ func NewAgentInstance( sessionsManager := session.NewLegacyAdapter(store) - contextBuilder := NewContextBuilder(workspace) + mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled + contextBuilder := NewContextBuilder(workspace).WithToolDiscovery( + mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25, + mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex, + ) agentID := routing.DefaultAgentID - agentName := "" - var subagents *config.SubagentsConfig - var skillsFilter []string if agentCfg != nil { agentID = routing.NormalizeAgentID(agentCfg.ID) - agentName = agentCfg.Name - subagents = agentCfg.Subagents - skillsFilter = agentCfg.Skills } // Apply defaults.Orchestration: if the flag is set, ensure orchestration is enabled. - if defaults.Orchestration { if subagents == nil { subagents = &config.SubagentsConfig{Enabled: true} @@ -182,54 +174,59 @@ func NewAgentInstance( } maxIter := defaults.MaxToolIterations - if maxIter == 0 { maxIter = 20 } reminderInterval := defaults.TaskReminderInterval - if reminderInterval == 0 { reminderInterval = 5 } maxTokens := defaults.MaxTokens - if maxTokens == 0 { maxTokens = 8192 } temperature := 0.7 - if defaults.Temperature != nil { temperature = *defaults.Temperature } - // Resolve fallback candidates + var thinkingLevelStr string + if mc, err := cfg.GetModelConfig(model); err == nil { + thinkingLevelStr = mc.ThinkingLevel + } + thinkingLevel := parseThinkingLevel(thinkingLevelStr) - modelCfg := providers.ModelConfig{ - Primary: model, - - Fallbacks: fallbacks, + summarizeMessageThreshold := defaults.SummarizeMessageThreshold + if summarizeMessageThreshold == 0 { + summarizeMessageThreshold = 20 } + summarizeTokenPercent := defaults.SummarizeTokenPercent + if summarizeTokenPercent == 0 { + summarizeTokenPercent = 75 + } + + // Resolve fallback candidates + modelCfg := providers.ModelConfig{ + Primary: model, + Fallbacks: fallbacks, + } resolveFromModelList := func(raw string) (string, bool) { ensureProtocol := func(model string) string { model = strings.TrimSpace(model) - if model == "" { return "" } - if strings.Contains(model, "/") { return model } - return "openai/" + model } raw = strings.TrimSpace(raw) - if raw == "" { return "", false } @@ -241,17 +238,13 @@ func NewAgentInstance( for i := range cfg.ModelList { fullModel := strings.TrimSpace(cfg.ModelList[i].Model) - if fullModel == "" { continue } - if fullModel == raw { return ensureProtocol(fullModel), true } - _, modelID := providers.ExtractProtocol(fullModel) - if modelID == raw { return ensureProtocol(fullModel), true } @@ -264,76 +257,73 @@ func NewAgentInstance( candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) // Resolve plan model (for interviewing/review phases) - planModel := resolvePlanModel(agentCfg, defaults) - planFallbacks := resolvePlanFallbacks(agentCfg, defaults) var planCandidates []providers.FallbackCandidate - if planModel != "" { planModelCfg := providers.ModelConfig{ - Primary: planModel, - + Primary: planModel, Fallbacks: planFallbacks, } - planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider) } + // Model routing setup: pre-resolve light model candidates at creation time + // to avoid repeated model_list lookups on every incoming message. + var router *routing.Router + var lightCandidates []providers.FallbackCandidate + if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { + lightModelCfg := providers.ModelConfig{Primary: rc.LightModel} + resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList) + if len(resolved) > 0 { + router = routing.New(routing.RouterConfig{ + LightModel: rc.LightModel, + Threshold: rc.Threshold, + }) + lightCandidates = resolved + } else { + log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q", + rc.LightModel, agentID) + } + } + // Startup cleanup: prune orphaned worktrees - worktreesDir := filepath.Join(workspace, ".worktrees") - if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" { git.PruneOrphaned(repoRoot, worktreesDir) } return &AgentInstance{ - ID: agentID, - - Name: agentName, - - Model: model, - - Fallbacks: fallbacks, - - Workspace: workspace, - - MaxIterations: maxIter, - - TaskReminderInterval: reminderInterval, - - MaxTokens: maxTokens, - - Temperature: temperature, - - ContextWindow: maxTokens, - - Provider: provider, - - Sessions: sessionsManager, - - ContextBuilder: contextBuilder, - - Tools: toolsRegistry, - - Subagents: subagents, - - SkillsFilter: skillsFilter, - - Candidates: candidates, - - PlanModel: planModel, - - PlanFallbacks: planFallbacks, - - PlanCandidates: planCandidates, + ID: agentID, + Name: agentName, + Model: model, + Fallbacks: fallbacks, + Workspace: workspace, + MaxIterations: maxIter, + TaskReminderInterval: reminderInterval, + MaxTokens: maxTokens, + Temperature: temperature, + ThinkingLevel: thinkingLevel, + ContextWindow: maxTokens, + SummarizeMessageThreshold: summarizeMessageThreshold, + SummarizeTokenPercent: summarizeTokenPercent, + Provider: provider, + Sessions: sessionsManager, + ContextBuilder: contextBuilder, + Tools: toolsRegistry, + Subagents: subagents, + SkillsFilter: skillsFilter, + Candidates: candidates, + PlanModel: planModel, + PlanFallbacks: planFallbacks, + PlanCandidates: planCandidates, + Router: router, + LightCandidates: lightCandidates, } } // resolveAgentWorkspace determines the workspace directory for an agent. - func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { return expandHome(strings.TrimSpace(agentCfg.Workspace)) @@ -344,75 +334,58 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD } home, _ := os.UserHomeDir() - id := routing.NormalizeAgentID(agentCfg.ID) - return filepath.Join(home, ".picoclaw", "workspace-"+id) } // resolveAgentModel resolves the primary model for an agent. - func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" { return strings.TrimSpace(agentCfg.Model.Primary) } - return defaults.GetModelName() } // resolveAgentFallbacks resolves the fallback models for an agent. - func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string { if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil { return agentCfg.Model.Fallbacks } - return defaults.ModelFallbacks } // resolvePlanModel resolves the plan model for an agent (used during interviewing/review phases). - func resolvePlanModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && agentCfg.PlanModel != nil && strings.TrimSpace(agentCfg.PlanModel.Primary) != "" { return strings.TrimSpace(agentCfg.PlanModel.Primary) } - return defaults.PlanModel } // resolvePlanFallbacks resolves the plan model fallbacks for an agent. - func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string { if agentCfg != nil && agentCfg.PlanModel != nil && agentCfg.PlanModel.Fallbacks != nil { return agentCfg.PlanModel.Fallbacks } - return defaults.PlanModelFallbacks } // ActivateWorktree creates a worktree for a session. - // projectDir is the git repository to create the worktree in. - // If empty, falls back to ai.Workspace. - // Worktree path: <workspace>/.worktrees/<branch-basename>/ - func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir string) (*git.WorktreeInfo, error) { if projectDir == "" { projectDir = ai.Workspace } repoRoot := git.FindRepoRoot(projectDir) - if repoRoot == "" { return nil, fmt.Errorf("directory is not a git repository: %s", projectDir) } branchName := git.SanitizeBranchName(taskName) - baseName := git.BranchBaseName(branchName) - wtPath := filepath.Join(ai.Workspace, ".worktrees", baseName) wt, err := git.CreateWorktree(repoRoot, wtPath, branchName) @@ -421,29 +394,22 @@ func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir strin } ai.worktreeMu.Lock() - if ai.worktrees == nil { ai.worktrees = make(map[string]*git.WorktreeInfo) } - ai.worktrees[sessionKey] = wt - ai.worktreeMu.Unlock() return wt, nil } // DeactivateWorktree safe-disposes the session's worktree. - func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) { ai.worktreeMu.Lock() - wt, ok := ai.worktrees[sessionKey] - if ok { delete(ai.worktrees, sessionKey) } - ai.worktreeMu.Unlock() if !ok || wt == nil { @@ -451,72 +417,70 @@ func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discar } repoRoot := git.FindRepoRoot(ai.Workspace) - if repoRoot == "" { return nil, fmt.Errorf("workspace is not a git repository") } // Even on discard, SafeDispose auto-commits first for safety - if commitMsg != "" && git.HasUncommittedChanges(wt.Path) { _ = git.AutoCommit(wt.Path, commitMsg) } result := git.SafeDispose(repoRoot, wt) - return &result, nil } // GetWorktree returns the session's active worktree, or nil. - func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo { ai.worktreeMu.RLock() - defer ai.worktreeMu.RUnlock() - return ai.worktrees[sessionKey] } // IsInWorktree returns true if the session has an active worktree. - func (ai *AgentInstance) IsInWorktree(sessionKey string) bool { return ai.GetWorktree(sessionKey) != nil } // EffectiveWorkspace returns worktree path for session, or original Workspace. - func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string { if wt := ai.GetWorktree(sessionKey); wt != nil { return wt.Path } - return ai.Workspace } // GetWorktreeBranch returns the branch name for the session's worktree, or "". - func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string { if wt := ai.GetWorktree(sessionKey); wt != nil { return wt.Branch } - return "" } +func compilePatterns(patterns []string) []*regexp.Regexp { + compiled := make([]*regexp.Regexp, 0, len(patterns)) + for _, p := range patterns { + re, err := regexp.Compile(p) + if err != nil { + fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err) + continue + } + compiled = append(compiled, re) + } + return compiled +} + func expandHome(path string) string { if path == "" { return path } - if path[0] == '~' { home, _ := os.UserHomeDir() - if len(path) > 1 && path[1] == '/' { return home + path[1:] } - return home } - return path } diff --git a/pkg/agent/instance_ext_test.go b/pkg/agent/instance_ext_test.go new file mode 100644 index 000000000..59e1f04e2 --- /dev/null +++ b/pkg/agent/instance_ext_test.go @@ -0,0 +1,53 @@ +package agent + +import ( + "os" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "glm-5", + }, + }, + + ModelList: []config.ModelConfig{ + { + ModelName: "glm-5", + + Model: "glm-5", + + APIBase: "https://api.z.ai/api/coding/paas/v4", + }, + }, + } + + provider := &mockProvider{} + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + + if len(agent.Candidates) != 1 { + t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) + } + + if agent.Candidates[0].Provider != "openai" { + t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai") + } + + if agent.Candidates[0].Model != "glm-5" { + t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5") + } +} diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 9762d0a3c..4f41ecd1c 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -12,35 +12,28 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 1234, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 1234, MaxToolIterations: 5, }, }, } configuredTemp := 1.0 - cfg.Agents.Defaults.Temperature = &configuredTemp provider := &mockProvider{} - agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) if agent.MaxTokens != 1234 { t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234) } - if agent.Temperature != 1.0 { t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0) } @@ -51,29 +44,23 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 1234, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 1234, MaxToolIterations: 5, }, }, } configuredTemp := 0.0 - cfg.Agents.Defaults.Temperature = &configuredTemp provider := &mockProvider{} - agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) if agent.Temperature != 0.0 { @@ -86,25 +73,20 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 1234, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 1234, MaxToolIterations: 5, }, }, } provider := &mockProvider{} - agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) if agent.Temperature != 0.7 { @@ -113,91 +95,68 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) { } func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "step-3.5-flash", - }, + tests := []struct { + name string + aliasName string + modelName string + apiBase string + wantProvider string + wantModel string + }{ + { + name: "alias with provider prefix", + aliasName: "step-3.5-flash", + modelName: "openrouter/stepfun/step-3.5-flash:free", + apiBase: "https://openrouter.ai/api/v1", + wantProvider: "openrouter", + wantModel: "stepfun/step-3.5-flash:free", }, - - ModelList: []config.ModelConfig{ - { - ModelName: "step-3.5-flash", - - Model: "openrouter/stepfun/step-3.5-flash:free", - - APIBase: "https://openrouter.ai/api/v1", - }, + { + name: "alias without provider prefix", + aliasName: "glm-5", + modelName: "glm-5", + apiBase: "https://api.z.ai/api/coding/paas/v4", + wantProvider: "openai", + wantModel: "glm-5", }, } - provider := &mockProvider{} + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) - agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: tt.aliasName, + }, + }, + ModelList: []config.ModelConfig{ + { + ModelName: tt.aliasName, + Model: tt.modelName, + APIBase: tt.apiBase, + }, + }, + } - if len(agent.Candidates) != 1 { - t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) - } + provider := &mockProvider{} + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) - if agent.Candidates[0].Provider != "openrouter" { - t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter") - } - - if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" { - t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free") - } -} - -func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-instance-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "glm-5", - }, - }, - - ModelList: []config.ModelConfig{ - { - ModelName: "glm-5", - - Model: "glm-5", - - APIBase: "https://api.z.ai/api/coding/paas/v4", - }, - }, - } - - provider := &mockProvider{} - - agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider) - - if len(agent.Candidates) != 1 { - t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) - } - - if agent.Candidates[0].Provider != "openai" { - t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai") - } - - if agent.Candidates[0].Model != "glm-5" { - t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5") + if len(agent.Candidates) != 1 { + t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates)) + } + if agent.Candidates[0].Provider != tt.wantProvider { + t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider) + } + if agent.Candidates[0].Model != tt.wantModel { + t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel) + } + }) } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 73c825ae8..259f2cd28 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -1,11 +1,7 @@ // 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 @@ -17,16 +13,14 @@ import ( "fmt" "path/filepath" "regexp" - "strconv" "strings" "sync" "sync/atomic" "time" - "unicode" - "unicode/utf8" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/git" @@ -34,7 +28,6 @@ import ( "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/skills" @@ -42,72 +35,9 @@ import ( "github.com/sipeed/picoclaw/pkg/stats" "github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/utils" + "github.com/sipeed/picoclaw/pkg/voice" ) -// activeTask tracks a running agent task for live status and intervention. - -type activeTask struct { - Description string - - Result string // LLM response summary for completion notification - - Iteration int - - MaxIter int - - StartedAt time.Time - - cancel context.CancelFunc - - interrupt chan string // buffered 1, for user message injection - - toolLog []toolLogEntry - - lastError *toolLogEntry // sticky: most recent error, persists across iterations - - projectDir string // detected from exec cd target (authoritative) - - fileCommonDir string // LCP of file paths relative to workspace (fallback) - - streamedChunks bool // true after onChunk fires at least once - - messageContent string // last content sent by the message tool (for inclusion in completion) - - mu sync.Mutex -} - -// toolLogEntry records a single tool call for the live terminal view. - -type toolLogEntry struct { - Name string - - ArgsSnip string // first ~80 chars of args - - Result string // "✓ 4.9s" or "✗ 3.2s" - - ErrDetail string // non-empty on error — e.g. "Exit code: exit status 1" -} - -// maxToolLogEntries limits the sliding window of tool log entries - -// kept in memory and displayed in status messages. - -const maxToolLogEntries = 5 - -// sessionSemaphore is a per-session mutex using a buffered channel. - -type sessionSemaphore struct { - ch chan struct{} -} - -func newSessionSemaphore() *sessionSemaphore { - s := &sessionSemaphore{ch: make(chan struct{}, 1)} - - s.ch <- struct{}{} // initially unlocked - - return s -} - type AgentLoop struct { bus *bus.MessageBus @@ -129,6 +59,12 @@ type AgentLoop struct { mediaStore media.MediaStore + transcriber voice.Transcriber + + cmdRegistry *commands.Registry + + mcp mcpRuntime + providerCache map[string]providers.LLMProvider planStartPending bool // set by /plan start to trigger LLM execution @@ -161,7 +97,6 @@ type AgentLoop struct { } // processOptions configures how a message is processed - type processOptions struct { SessionKey string // Session identifier for history/context @@ -171,6 +106,8 @@ type processOptions struct { UserMessage string // User message content (may include prefix) + Media []string // media:// refs from inbound message + HistoryMessage string // If set, save this to history instead of UserMessage (for skill compaction) DefaultResponse string // Response when LLM returns empty @@ -188,13 +125,19 @@ type processOptions struct { SystemMessage bool // If true, this is a system message (subagent result) — skip placeholder and plan nudge } -const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." +const ( + defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json." + sessionKeyAgentPrefix = "agent:" + metadataKeyAccountID = "account_id" + metadataKeyGuildID = "guild_id" + metadataKeyTeamID = "team_id" + metadataKeyParentPeerKind = "parent_peer_kind" + metadataKeyParentPeerID = "parent_peer_id" +) func NewAgentLoop( cfg *config.Config, - msgBus *bus.MessageBus, - provider providers.LLMProvider, enableStats ...bool, @@ -202,17 +145,12 @@ func NewAgentLoop( registry := NewAgentRegistry(cfg, provider) // Set up shared fallback chain - cooldown := providers.NewCooldownTracker() - fallbackChain := providers.NewFallbackChain(cooldown) // Create state manager using default agent's workspace for channel recording - defaultAgent := registry.GetDefaultAgent() - var stateManager *state.Manager - if defaultAgent != nil { stateManager = state.NewManager(defaultAgent.Workspace) } @@ -271,6 +209,8 @@ func NewAgentLoop( orchReporter: orchReporter, done: make(chan struct{}), + + cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), } // Register shared tools to all agents (needs al for reporter injection). @@ -282,46 +222,6 @@ func NewAgentLoop( return al } -// reporter returns the active AgentReporter (never nil). - -func (al *AgentLoop) reporter() orch.AgentReporter { - if al.orchReporter == nil { - return orch.Noop - } - - return al.orchReporter -} - -// SetOrchReporter wires a Broadcaster as the active reporter. - -// Called from cmd_gateway.go when --orchestration is set. - -// --orchestration なし → 呼ばれない → reporter() は Noop を返す。 - -func (al *AgentLoop) SetOrchReporter(b *orch.Broadcaster) { - al.orchBroadcaster = b - - al.orchReporter = b -} - -// GetOrchBroadcaster returns the concrete Broadcaster for miniapp wiring. - -// Returns nil when orchestration is disabled. - -func (al *AgentLoop) GetOrchBroadcaster() *orch.Broadcaster { - return al.orchBroadcaster -} - -func (al *AgentLoop) notifyStateChange() { - al.promptDirty.Store(true) - - if al.OnStateChange != nil { - al.OnStateChange() - } -} - -// SetConfigSaver registers a callback used by slash commands that persist runtime config changes. - func (al *AgentLoop) SetConfigSaver(fn func(*config.Config) error) { al.saveConfig = fn } @@ -333,159 +233,161 @@ func (al *AgentLoop) SetHeartbeatThreadUpdater(fn func(int)) { } // registerSharedTools registers tools that are shared across all agents (web, message, spawn). - func registerSharedTools( cfg *config.Config, - msgBus *bus.MessageBus, - registry *AgentRegistry, - provider providers.LLMProvider, al *AgentLoop, ) { for _, agentID := range registry.ListAgentIDs() { agent, ok := registry.GetAgent(agentID) - if !ok { continue } // Web tools - - searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - - TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, - - 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, - - PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, - - PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, - - PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, - - Proxy: cfg.Tools.Web.Proxy, - }) - - if err != nil { - logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{ - "agent_id": agentID, - - "error": err.Error(), + if cfg.Tools.IsToolEnabled("web") { + searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ + BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys), + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys), + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKeys: config.MergeAPIKeys( + cfg.Tools.Web.Perplexity.APIKey, + cfg.Tools.Web.Perplexity.APIKeys, + ), + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, + SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, + SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, + GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey, + GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, + GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, + GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, + GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, + Proxy: cfg.Tools.Web.Proxy, }) - } else if searchTool != nil { - agent.Tools.Register(searchTool) + if err != nil { + logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{ + "agent_id": agentID, + "error": err.Error(), + }) + } else if searchTool != nil { + agent.Tools.Register(searchTool) - logger.InfoCF("agent", "Web search provider registered", map[string]any{ - "agent_id": agentID, - - "provider": searchTool.ProviderName(), - }) - } else { - logger.WarnCF("agent", "No web search provider configured", map[string]any{ - "agent_id": agentID, - }) + logger.InfoCF("agent", "Web search provider registered", map[string]any{ + "agent_id": agentID, + "provider": searchTool.ProviderName(), + }) + } else { + logger.WarnCF("agent", "No web search provider configured", map[string]any{ + "agent_id": agentID, + }) + } } - fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy) - - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{ - "agent_id": agentID, - - "error": err.Error(), - }) - } else { - agent.Tools.Register(fetchTool) + if cfg.Tools.IsToolEnabled("web_fetch") { + fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{ + "agent_id": agentID, + "error": err.Error(), + }) + } else { + agent.Tools.Register(fetchTool) + } } // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms - - agent.Tools.Register(tools.NewI2CTool()) - - agent.Tools.Register(tools.NewSPITool()) + 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 := tools.NewMessageTool() + messageTool.SetSendCallback(func(channel, chatID, content string) error { + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - messageTool.SetSendCallback(func(channel, chatID, content string) error { - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() - defer pubCancel() + return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: channel, - return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channel, + ChatID: chatID, - ChatID: chatID, - - Content: content, + Content: content, + }) }) - }) - agent.Tools.Register(messageTool) + agent.Tools.Register(messageTool) + } + + // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) + if cfg.Tools.IsToolEnabled("send_file") { + sendFileTool := tools.NewSendFileTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + ) + agent.Tools.Register(sendFileTool) + } // Skill discovery and installation tools + skills_enabled := cfg.Tools.IsToolEnabled("skills") + find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") + install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") + if skills_enabled && (find_skills_enable || install_skills_enable) { + registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ + MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, + ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + }) - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, + 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)) + } - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), - }) - - searchCache := skills.NewSearchCache( - - cfg.Tools.Skills.SearchCache.MaxSize, - - time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, - ) - - agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) - - agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) + if install_skills_enable { + agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) + } + } // Spawn tool — only registered when orchestration is explicitly enabled. if agent.Subagents != nil && agent.Subagents.Enabled { webSearchOpts := tools.WebSearchToolOptions{ - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - - TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, - - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, - + BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys), + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys), + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - - PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, - + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKeys: config.MergeAPIKeys( + cfg.Tools.Web.Perplexity.APIKey, + cfg.Tools.Web.Perplexity.APIKeys, + ), PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, - - PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, } subagentManager := tools.NewSubagentManager( @@ -553,6 +455,10 @@ func registerSharedTools( func (al *AgentLoop) Run(ctx context.Context) error { al.running.Store(true) + if err := al.ensureMCPInitialized(ctx); err != nil { + return err + } + // LLM work is dispatched to a background worker so the main loop // stays free to handle slash commands (/skills, …) instantly, @@ -578,9 +484,7 @@ func (al *AgentLoop) Run(ctx context.Context) error { for al.running.Load() { select { case <-ctx.Done(): - return nil - default: } @@ -762,6 +666,16 @@ func (al *AgentLoop) Close() { close(al.done) } + 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(), + }) + } + } + if al.stats != nil { al.stats.Close() } @@ -771,48 +685,8 @@ func (al *AgentLoop) Close() { agent.Sessions.Close() } } -} -// gcLoop periodically cleans up stale sessionLock entries. - -func (al *AgentLoop) gcLoop() { - ticker := time.NewTicker(30 * time.Minute) - - defer ticker.Stop() - - for { - select { - case <-ticker.C: - - al.gcSessionLocks() - - case <-al.done: - - return - } - } -} - -// gcSessionLocks removes unlocked (idle) sessionSemaphore entries from the map. - -func (al *AgentLoop) gcSessionLocks() { - al.sessionLocks.Range(func(key, val any) bool { - sem := val.(*sessionSemaphore) - - select { - case <-sem.ch: - - // Was unlocked — safe to remove - - al.sessionLocks.Delete(key) - - default: - - // Currently locked — in use, keep - } - - return true - }) + al.registry.Close() } func (al *AgentLoop) RegisterTool(tool tools.Tool) { @@ -886,44 +760,141 @@ func (al *AgentLoop) resolveProvider( func (al *AgentLoop) SetMediaStore(s media.MediaStore) { al.mediaStore = s + + // Propagate store to send_file tools in all agents. + al.registry.ForEachTool("send_file", func(t tools.Tool) { + if sf, ok := t.(*tools.SendFileTool); ok { + sf.SetMediaStore(s) + } + }) +} + +// SetTranscriber injects a voice transcriber for agent-level audio transcription. +func (al *AgentLoop) SetTranscriber(t voice.Transcriber) { + al.transcriber = t +} + +var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) + +// transcribeAudioInMessage resolves audio media refs, transcribes them, and +// replaces audio annotations in msg.Content with the transcribed text. +// Returns the (possibly modified) message and true if audio was transcribed. +func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) (bus.InboundMessage, bool) { + if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 { + return msg, false + } + + // Transcribe each audio media ref in order. + var transcriptions []string + for _, ref := range msg.Media { + path, meta, err := al.mediaStore.ResolveWithMeta(ref) + if err != nil { + logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) + continue + } + if !utils.IsAudioFile(meta.Filename, meta.ContentType) { + continue + } + result, err := al.transcriber.Transcribe(ctx, path) + if err != nil { + logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) + transcriptions = append(transcriptions, "") + continue + } + transcriptions = append(transcriptions, result.Text) + } + + if len(transcriptions) == 0 { + return msg, false + } + + al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions) + + // Replace audio annotations sequentially with transcriptions. + idx := 0 + newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { + if idx >= len(transcriptions) { + return match + } + text := transcriptions[idx] + idx++ + return "[voice: " + text + "]" + }) + + // Append any remaining transcriptions not matched by an annotation. + for ; idx < len(transcriptions); idx++ { + newContent += "\n[voice: " + transcriptions[idx] + "]" + } + + msg.Content = newContent + return msg, true +} + +// sendTranscriptionFeedback sends feedback to the user with the result of +// audio transcription if the option is enabled. It uses Manager.SendMessage +// which executes synchronously (rate limiting, splitting, retry) so that +// ordering with the subsequent placeholder is guaranteed. +func (al *AgentLoop) sendTranscriptionFeedback( + ctx context.Context, + channel, chatID, messageID string, + validTexts []string, +) { + if !al.cfg.Voice.EchoTranscription { + return + } + if al.channelManager == nil { + return + } + + var nonEmpty []string + for _, t := range validTexts { + if t != "" { + nonEmpty = append(nonEmpty, t) + } + } + + var feedbackMsg string + if len(nonEmpty) > 0 { + feedbackMsg = "Transcript: " + strings.Join(nonEmpty, "\n") + } else { + feedbackMsg = "No voice detected in the audio" + } + + err := al.channelManager.SendMessage(ctx, bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: feedbackMsg, + ReplyToMessageID: messageID, + }) + if err != nil { + logger.WarnCF("voice", "Failed to send transcription feedback", map[string]any{"error": err.Error()}) + } } // inferMediaType determines the media type ("image", "audio", "video", "file") - // from a filename and MIME content type. - func inferMediaType(filename, contentType string) string { ct := strings.ToLower(contentType) - fn := strings.ToLower(filename) if strings.HasPrefix(ct, "image/") { return "image" } - if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" { return "audio" } - if strings.HasPrefix(ct, "video/") { return "video" } // Fallback: infer from extension - ext := filepath.Ext(fn) - switch ext { case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": - return "image" - case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus": - return "audio" - case ".mp4", ".avi", ".mov", ".webm", ".mkv": - return "video" } @@ -931,26 +902,20 @@ func inferMediaType(filename, contentType string) string { } // 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) } @@ -974,9 +939,12 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri func (al *AgentLoop) ProcessDirectWithChannel( ctx context.Context, - content, sessionKey, channel, chatID string, ) (string, error) { + if err := al.ensureMCPInitialized(ctx); err != nil { + return "", err + } + msg := bus.InboundMessage{ Channel: channel, @@ -997,12 +965,10 @@ func (al *AgentLoop) ProcessDirectWithChannel( } // ProcessHeartbeat processes a heartbeat request without session history. - // Each heartbeat is independent and doesn't accumulate context. func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) { agent := al.registry.GetDefaultAgent() - if agent == nil { return "", fmt.Errorf("no default agent for heartbeat") } @@ -1039,9 +1005,7 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) { // Add message preview to log (show full content for error messages) - var logContent string - if strings.Contains(msg.Content, "Error:") || strings.Contains(msg.Content, "error") { logContent = msg.Content // Full content for errors } else { @@ -1060,6 +1024,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "session_key": msg.SessionKey, }) + // Transcribe audio in the message if a transcriber is configured. + 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) + } + // Handle reply-based intervention for active tasks if taskID, ok := msg.Metadata["task_id"]; ok && taskID != "" { @@ -1122,7 +1096,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } // Route system messages to processSystemMessage - if msg.Channel == "system" { return al.processSystemMessage(ctx, msg) } @@ -1174,11 +1147,9 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) }) agent, ok := al.registry.GetAgent(route.AgentID) - if !ok { agent = al.registry.GetDefaultAgent() } - if agent == nil { return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) } @@ -1218,6 +1189,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) UserMessage: msg.Content, + Media: msg.Media, + HistoryMessage: expansionCompact, DefaultResponse: defaultResponse, @@ -1230,232 +1203,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) }) } -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<actual content>" - - 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 - } - - // Inject subagent result into session history without running a full LLM loop. - - // The conductor will see the result on its next turn. This avoids: - - // - Flooding the chat with a response for every subagent completion - - // - Consuming the Telegram "Thinking..." placeholder - - // - Wasting LLM tokens on processing each result individually - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return "", fmt.Errorf("no default agent for system message") - } - - sessionKey := routing.BuildAgentMainSessionKey(agent.ID) - - historyMsg := fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content) - - // Write as TurnReport to the store for DAG tracking, with legacy fallback. - - subagentSessionKey := routing.BuildSubagentSessionKey(extractTaskID(msg.SenderID)) - - store := agent.Sessions.Store() - - reportTurn := &session.Turn{ - Kind: session.TurnReport, - - OriginKey: subagentSessionKey, - - Author: msg.SenderID, - - Messages: []providers.Message{{Role: "user", Content: historyMsg}}, - } - - if err := store.Append(sessionKey, reportTurn); err != nil { - logger.ErrorCF("agent", "Failed to record report turn, falling back to legacy", - - map[string]any{"error": err.Error()}) - - agent.Sessions.AddMessage(sessionKey, "user", historyMsg) - - agent.Sessions.MarkDirty(sessionKey) - } else { - // Update in-memory cache so conductor sees the message on next turn. - - agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: historyMsg}) - - agent.Sessions.AdvanceStored(sessionKey, 1) - } - - // Send a brief notification (SkipPlaceholder to avoid corrupting status messages) - - label := msg.SenderID - - if idx := strings.LastIndex(label, ":"); idx >= 0 { - label = label[idx+1:] - } - - notification := formatSubagentCompletion(label, msg.Metadata) - - subagentThreadID := 0 - - if al.cfg != nil { - subagentThreadID = al.cfg.Channels.Telegram.SubagentThreadID - } - - notifyChatID := al.withTelegramThread(originChannel, originChatID, subagentThreadID) - - _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: originChannel, - - ChatID: notifyChatID, - - Content: notification, - - SkipPlaceholder: true, - }) - - logger.InfoCF("agent", "Subagent result injected into session history", - - map[string]any{ - "sender_id": msg.SenderID, - - "session_key": sessionKey, - - "content_len": len(content), - }) - - return "", nil -} - -// extractTaskID extracts the task ID from a sender ID like "subagent:subagent-1". - -func extractTaskID(senderID string) string { - if idx := strings.LastIndex(senderID, ":"); idx >= 0 { - return senderID[idx+1:] - } - - return senderID -} - -// formatSubagentCompletion builds the user-facing notification for a completed subagent. - -// If metadata contains duration_ms and tool_calls it produces e.g.: - -// - -// "📋 scout-1 completed (3.2s, 5 tool calls)." - -// - -// Without metadata it falls back to the plain "📋 scout-1 completed." format. - -func formatSubagentCompletion(label string, metadata map[string]string) string { - if len(metadata) == 0 { - return fmt.Sprintf("📋 %s completed.", label) - } - - durationMs, _ := strconv.ParseInt(metadata["duration_ms"], 10, 64) - - toolCalls, _ := strconv.Atoi(metadata["tool_calls"]) - - if durationMs <= 0 && toolCalls <= 0 { - return fmt.Sprintf("📋 %s completed.", label) - } - - parts := make([]string, 0, 2) - - if durationMs > 0 { - parts = append(parts, formatDurationMs(durationMs)) - } - - if toolCalls > 0 { - if toolCalls == 1 { - parts = append(parts, "1 tool call") - } else { - parts = append(parts, fmt.Sprintf("%d tool calls", toolCalls)) - } - } - - return fmt.Sprintf("📋 %s completed (%s).", label, strings.Join(parts, ", ")) -} - -// formatDurationMs converts milliseconds to a human-readable duration string. - -// Examples: 800 → "0.8s", 1200 → "1.2s", 65000 → "1m5s", 3661000 → "61m1s". - -func formatDurationMs(ms int64) string { - if ms < 1000 { - return fmt.Sprintf("%dms", ms) - } - - totalSec := ms / 1000 - - if totalSec < 60 { - tenths := (ms % 1000) / 100 - - return fmt.Sprintf("%d.%ds", totalSec, tenths) - } - - mins := totalSec / 60 - - sec := totalSec % 60 - - if sec == 0 { - return fmt.Sprintf("%dm", mins) - } - - return fmt.Sprintf("%dm%ds", mins, sec) -} - func (al *AgentLoop) withTelegramThread(channel, chatID string, threadID int) string { if channel != "telegram" || threadID <= 0 || chatID == "" { return chatID @@ -1474,38 +1221,6 @@ func (al *AgentLoop) withTelegramThread(channel, chatID string, threadID int) st return fmt.Sprintf("%s/%d", baseChatID, threadID) } -// acquireSessionLock gets or creates a per-session semaphore and acquires it. - -// Returns false if the context is canceled before the lock is acquired. - -func (al *AgentLoop) acquireSessionLock(ctx context.Context, sessionKey string) bool { - val, _ := al.sessionLocks.LoadOrStore(sessionKey, newSessionSemaphore()) - - sem := val.(*sessionSemaphore) - - select { - case <-sem.ch: - - return true - - case <-ctx.Done(): - - return false - } -} - -// releaseSessionLock releases the per-session semaphore. - -func (al *AgentLoop) releaseSessionLock(sessionKey string) { - if val, ok := al.sessionLocks.Load(sessionKey); ok { - sem := val.(*sessionSemaphore) - - sem.ch <- struct{}{} - } -} - -// runAgentLoop is the core message processing logic. - func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) { // -1. Acquire per-session lock to prevent concurrent access on the same session @@ -1794,7 +1509,6 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt if !constants.IsInternalChannel(opts.Channel) { channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID) - if err := al.RecordLastChannel(channelKey); err != nil { logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()}) } @@ -1896,12 +1610,9 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 2. Build messages (skip history for heartbeat) var history []providers.Message - var summary string - if !opts.NoHistory { history = agent.Sessions.GetHistory(opts.SessionKey) - summary = agent.Sessions.GetSummary(opts.SessionKey) // Sanitize history to remove orphaned tool calls (from crashes/session collisions) @@ -1926,22 +1637,21 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt _ = agent.Sessions.Save(opts.SessionKey) } } - messages := agent.ContextBuilder.BuildMessages( - history, - summary, - opts.UserMessage, - nil, + opts.Media, opts.Channel, - opts.ChatID, ) + // Resolve media:// refs to base64 data URLs (streaming) + maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize() + messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + // 2b. Interview staleness nudge: if MEMORY.md hasn't been updated for // several consecutive turns, inject a reminder so the AI writes its findings. @@ -1979,7 +1689,6 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt sb.WriteString("MEMORY.md is the only shared state between heartbeats. ") sb.WriteString( - "After completing each plan step, immediately use edit_file to mark it [x] in memory/MEMORY.md.", ) @@ -2037,7 +1746,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt agent.Sessions.GetSummary(opts.SessionKey), - "", nil, opts.Channel, opts.ChatID, + "", opts.Media, opts.Channel, opts.ChatID, ) messages = append(messages, providers.Message{ @@ -2264,9 +1973,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt // 9. Log response responsePreview := utils.Truncate(finalContent, 120) - logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview), - map[string]any{ "agent_id": agent.ID, @@ -2280,2288 +1987,554 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt return finalContent, nil } -// Task reminder constants and helpers. - -const ( - taskReminderMaxChars = 500 - - blockerMaxChars = 200 -) - -func shouldInjectReminder(iteration, interval int) bool { - if interval <= 0 { - return false - } - - return iteration > 1 && iteration%interval == 0 -} - -func buildTaskReminder(userMessage string, lastBlocker string) providers.Message { - truncatedTask := utils.Truncate(userMessage, taskReminderMaxChars) - - var content string - - if lastBlocker != "" { - truncatedBlocker := utils.Truncate(lastBlocker, blockerMaxChars) - - content = fmt.Sprintf( - "[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nLast blocker:\n---\n%s\n---\nFix the blocker if essential, or find an alternative. If all steps are complete, move on.", - truncatedTask, - truncatedBlocker, - ) - } else { - content = fmt.Sprintf( - "[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nIf all steps of the original task are complete, move on. Otherwise, continue with the next step.", - truncatedTask, - ) - } - - return providers.Message{ - Role: "user", - - Content: content, - } -} - -// interviewRejectMessage is the fixed rejection text injected when tool calls - -// are blocked during the interview phase. It is deliberately short to avoid - -// wasting tokens, and ends with a purpose reminder to steer the LLM back. - -const interviewRejectMessage = "[System] Tool call rejected. " + - - "You are in interview mode — ask the user questions and update MEMORY.md. " + - - "Do not execute, edit, or write project files." - -// buildPlanReminder returns a reminder message for plan pre-execution states - -// (interviewing / review) to keep the AI focused on the interview workflow - -// during tool-call iterations. - -func buildPlanReminder(planStatus string) (providers.Message, bool) { - var content string - - switch planStatus { - case "interviewing": - - content = "[System] You are interviewing the user to build a plan. " + - - "Ask clarifying questions and save findings to ## Context in memory/MEMORY.md using edit_file. " + - - "When you have enough information, write ## Phase sections with `- [ ]` checkbox steps, and ## Commands section. " + - - "Then change > Status: to review. Do NOT set it to executing." - - case "review": - - content = "[System] The plan is under review. " + - - "Wait for the user to approve or request changes. Do not proceed with execution." - - default: - - return providers.Message{}, false - } - - return providers.Message{Role: "user", Content: content}, true -} - -// buildOrchReminder returns a reminder to use spawn/subagent during plan execution. - -// Fires on first iteration and every 3rd iteration to reinforce delegation behavior. - -func buildOrchReminder(iteration int) (providers.Message, bool) { - if iteration != 1 && iteration%3 != 0 { - return providers.Message{}, false - } - - content := `[System] ORCHESTRATION mode active. You MUST delegate plan steps to subagents. - -Use spawn (non-blocking, returns immediately) or subagent (blocking, waits for result). - -Do NOT implement steps inline unless they are a single trivial tool call. - - - -To delegate, call the tool with JSON arguments: - - Tool: spawn Arguments: {"task": "...", "preset": "scout", "label": "..."} - - Tool: subagent Arguments: {"task": "...", "label": "..."} - - - -Spawn multiple independent steps in parallel for maximum throughput.` - - return providers.Message{Role: "user", Content: content}, true -} - -// cdPrefixPattern matches "cd /some/path && " at the start of a shell command. - -// Group 1 captures the target directory path. - -var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`) - -// optFlagPattern matches option flags like --verbose, -v, --timeout=60, -q. - -// Only standalone flags are removed; flags whose value is the next positional - -// argument (e.g. "-A 20") are kept because removing them would lose context. - -var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`) - -// extractExecProjectDir extracts the basename of an exec cd target. - -// Returns "" if the command has no cd prefix. - -func extractExecProjectDir(args map[string]any) string { - cmd, _ := args["command"].(string) - - if cmd == "" { - return "" - } - - m := cdPrefixPattern.FindStringSubmatch(cmd) - - if len(m) < 2 { - return "" - } - - cdPath := strings.TrimRight(m[1], "/\\") - - if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { - return cdPath[idx+1:] - } - - if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 { - return cdPath[idx+1:] - } - - return cdPath -} - -// fileParentRelDir returns the parent directory of a file path, relative to - -// workspace. Returns "" if the path is not under workspace or has no parent. - -func fileParentRelDir(filePath, workspace string) string { - ws := strings.TrimRight(workspace, "/\\") - - if ws == "" { - return "" - } - - rest := strings.TrimPrefix(filePath, ws) - - if rest == filePath { - return "" // not under workspace - } - - rest = strings.TrimLeft(rest, "/\\") - - // Remove the filename — keep only the directory part - - if idx := strings.LastIndexAny(rest, "/\\"); idx >= 0 { - return rest[:idx] - } - - return "" // file is directly under workspace, no meaningful dir -} - -// commonDirPrefix computes the longest common directory prefix of two - -// slash-separated paths. Returns "" if there is no common component. - -func commonDirPrefix(a, b string) string { - partsA := strings.Split(a, "/") - - partsB := strings.Split(b, "/") - - n := len(partsA) - - if len(partsB) < n { - n = len(partsB) - } - - common := 0 - - for i := 0; i < n; i++ { - if partsA[i] != partsB[i] { - break - } - - common = i + 1 - } - - if common == 0 { - return "" - } - - return strings.Join(partsA[:common], "/") -} - -// displayProjectDir returns the project directory name for status display. - -// Prefers the authoritative exec-based projectDir; falls back to the - -// basename of the file-based common directory. - -func displayProjectDir(task *activeTask) string { - if task.projectDir != "" { - return task.projectDir - } - - if task.fileCommonDir != "" { - dir := task.fileCommonDir - - if idx := strings.LastIndex(dir, "/"); idx >= 0 { - return dir[idx+1:] - } - - return dir - } - - return "" -} - 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 "" } -// buildArgsSnippet produces a human-friendly snippet for the tool log. - -// For exec: extracts the command and strips the leading "cd <workspace> && ". - -// For file tools: extracts the path and strips the workspace prefix. - -// Falls back to raw JSON truncation. - -func buildArgsSnippet(toolName string, args map[string]any, workspace string) string { - switch toolName { - case "exec": - - cmd, _ := args["command"].(string) - - if cmd == "" { - break - } - - cmd = cdPrefixPattern.ReplaceAllString(cmd, "") - - cmd = optFlagPattern.ReplaceAllString(cmd, "") - - return utils.Truncate(cmd, 80) - - case "read_file", "write_file", "edit_file", "append_file", "list_dir": - - path, _ := args["path"].(string) - - if path == "" { - break - } - - if workspace != "" { - path = strings.TrimPrefix(path, workspace) - - path = strings.TrimPrefix(path, "/") - } - - // Prioritize filename: if path is too long, show "…/filename" - - const maxPath = 60 - - if runes := []rune(path); len(runes) > maxPath { - // Find last slash to extract filename - - if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 { - filename := path[lastSlash:] // includes "/" - - dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…" - - if dirBudget > 0 { - dir := []rune(path[:lastSlash]) - - if len(dir) > dirBudget { - dir = dir[:dirBudget] - } - - path = string(dir) + "\u2026" + filename - } else { - path = "\u2026" + filename - } - } else { - path = utils.Truncate(path, maxPath) - } - } - - return path - } - - // Default: raw JSON truncated - - argsJSON, _ := json.Marshal(args) - - return utils.Truncate(string(argsJSON), 80) -} - -// maxEntryLineWidth is the max rune count for a single-line log entry. - -// Telegram chat bubbles on mobile are roughly 40-45 chars wide. - -const maxEntryLineWidth = 42 - -// isFileToolEntry returns true if the entry name contains a file-operation tool. - -func isFileToolEntry(name string) bool { - for _, t := range []string{"read_file", "write_file", "edit_file", "append_file", "list_dir"} { - if strings.Contains(name, t) { - return true - } - } - - return false -} - -// formatCompactEntry formats a finished tool log entry as a fixed single line. - -// The result marker (✓/✗) is always shown at the end regardless of truncation. - -// File tools omit duration (always near-instant); paths truncate from the - -// start so the filename is always visible. - -func formatCompactEntry(entry toolLogEntry) string { - result := entry.Result - - if result == "" { - result = "\u23F3" // ⏳ - } - - // File tools: strip duration, keep only marker (✓/✗/⏳) - - isFile := isFileToolEntry(entry.Name) - - if isFile { - if r := []rune(result); len(r) > 0 { - result = string(r[0:1]) // just the symbol - } - } - - // Budget for ArgsSnip: total - name - " " - " " - result - - nameLen := utf8.RuneCountInString(entry.Name) - - resultLen := utf8.RuneCountInString(result) - - argsBudget := maxEntryLineWidth - nameLen - 1 - 1 - resultLen - - args := entry.ArgsSnip - - if args != "" && argsBudget > 3 { - argsRunes := []rune(args) - - if len(argsRunes) > argsBudget { - // Paths: truncate from the start, keeping the filename visible - - if strings.Contains(args, "/") { - args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:]) - } else { - args = string(argsRunes[:argsBudget-1]) + "\u2026" - } - } - - var sb strings.Builder - - sb.Grow(len(entry.Name) + 1 + len(args) + 1 + len(result)) - - sb.WriteString(entry.Name) - - sb.WriteByte(' ') - - sb.WriteString(args) - - sb.WriteByte(' ') - - sb.WriteString(result) - - return sb.String() - } - - // No room for args or args empty - - var sb strings.Builder - - sb.Grow(len(entry.Name) + 1 + len(result)) - - sb.WriteString(entry.Name) - - sb.WriteByte(' ') - - sb.WriteString(result) - - return sb.String() -} - -// formatLatestEntry formats the latest entry command without its result marker. - -// Since the result goes on the next line, the full width is available for the command. - -func formatLatestEntry(entry toolLogEntry) string { - nameLen := utf8.RuneCountInString(entry.Name) - - argsBudget := maxEntryLineWidth - nameLen - 1 // name + space + args (no result) - - args := entry.ArgsSnip - - if args != "" && argsBudget > 3 { - argsRunes := []rune(args) - - if len(argsRunes) > argsBudget { - if strings.Contains(args, "/") { - args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:]) - } else { - args = string(argsRunes[:argsBudget-1]) + "\u2026" - } - } - - var sb strings.Builder - - sb.Grow(len(entry.Name) + 1 + len(args)) - - sb.WriteString(entry.Name) - - sb.WriteByte(' ') - - sb.WriteString(args) - - return sb.String() - } - - return entry.Name -} - -// compressRepeats reduces runs of 3+ identical non-alphanumeric, non-space - -// characters to just 2. e.g. "======" → "==", "---" → "--". - -func compressRepeats(s string) string { - runes := []rune(s) - - if len(runes) < 3 { - return s - } - - var sb strings.Builder - - sb.Grow(len(s)) - - i := 0 - - for i < len(runes) { - r := runes[i] - - if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsSpace(r) { - j := i + 1 - - for j < len(runes) && runes[j] == r { - j++ - } - - if j-i >= 3 { - sb.WriteRune(r) - - sb.WriteRune(r) - - i = j - - continue - } - } - - sb.WriteRune(r) - - i++ - } - - return sb.String() -} - -// Display layout constants. - -const ( - displayPastEntries = 4 // number of compact 1-line past entries - - displayErrorLines = 5 // content lines inside the error code block - - statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n" - - streamingDisplayLines = 17 // line count matching buildRichStatus output - -) - -// buildRichStatus builds a fixed-height terminal-like status display. - -// - -// Layout (always the same number of lines): - -// - -// 🔄 Task in progress (N/M) header - -// 📁 workspace-path header - -// ━━━━━━━━━━ separator - -// [N] compact-past-1 ✓ Xs past (1 line each) - -// [N] compact-past-2 ✗ Xs past - -// [N] compact-past-3 ✓ Xs past - -// [N] compact-past-4 ✓ Xs past - -// [N] latest-command latest (no result, wider args) - -// ⏳ latest result - -// reserved - -// ``` error fence - -// err-line / placeholder error body (5 lines) - -// ``` error fence - -// ↩️ Reply to intervene footer (background only) - -func buildRichStatus(task *activeTask, isBackground bool, workspace string) string { - task.mu.Lock() - - defer task.mu.Unlock() - - var sb strings.Builder - - // --- Header --- - - sb.WriteString("\U0001F504 Task in progress (") - - sb.WriteString(strconv.Itoa(task.Iteration)) - - sb.WriteByte('/') - - sb.WriteString(strconv.Itoa(task.MaxIter)) - - sb.WriteString(")\n") - - // Project directory: exec cd (authoritative) → file LCP → workspace basename - - sb.WriteString("\U0001F4C1 ") - - if dir := displayProjectDir(task); dir != "" { - sb.WriteString(dir) - } else if workspace != "" { - project := strings.TrimRight(workspace, "/\\") - - if idx := strings.LastIndex(project, "/"); idx >= 0 { - project = project[idx+1:] - } else if idx := strings.LastIndex(project, "\\"); idx >= 0 { - project = project[idx+1:] - } - - sb.WriteString(project) - } - - sb.WriteByte('\n') - - sb.WriteString(statusSeparator) - - // --- Task entries (displayPastEntries + 2 lines for latest) --- - - entries := task.toolLog - - if len(entries) > maxToolLogEntries { - entries = entries[len(entries)-maxToolLogEntries:] - } - - var pastEntries []toolLogEntry - - var latest *toolLogEntry - - if len(entries) > 0 { - latest = &entries[len(entries)-1] - - if len(entries) > 1 { - start := len(entries) - 1 - displayPastEntries - - if start < 0 { - start = 0 - } - - pastEntries = entries[start : len(entries)-1] - } - } - - // Past entries: exactly displayPastEntries lines (pad if fewer) - - for i := 0; i < displayPastEntries; i++ { - if i < len(pastEntries) { - sb.WriteString(formatCompactEntry(pastEntries[i])) - } else { - sb.WriteString("\u2800") - } - - sb.WriteByte('\n') - } - - // Latest entry: command on one line, result on next - - if latest != nil { - sb.WriteString(formatLatestEntry(*latest)) - - sb.WriteByte('\n') - - sb.WriteString(" ") - - if latest.Result != "" { - sb.WriteString(latest.Result) - } else { - sb.WriteString("\u23F3") - } - - sb.WriteByte('\n') - } else { - sb.WriteString("\u23F3 waiting...\n") - - sb.WriteString("\u2800\n") - } - - // Reserved (1 line) - - sb.WriteString("\u2800\n") - - // --- Error region (code fence, no separator) --- - - sb.WriteString("```\n") - - errEntry := task.lastError - - if errEntry != nil { - sb.WriteString("\u274C ") - - sb.WriteString(formatCompactEntry(*errEntry)) - - sb.WriteByte('\n') - - var detailLines []string - - if errEntry.ErrDetail != "" { - detailLines = strings.Split(errEntry.ErrDetail, "\n") - } - - for i := 0; i < displayErrorLines-1; i++ { - if i < len(detailLines) { - line := compressRepeats(detailLines[i]) - - if runes := []rune(line); len(runes) > maxEntryLineWidth { - line = string(runes[:maxEntryLineWidth-1]) + "\u2026" - } - - sb.WriteString(line) - } else { - sb.WriteString("\u2800") - } - - sb.WriteByte('\n') - } - } else { - sb.WriteString("\u2714 No errors\n") - - for i := 0; i < displayErrorLines-1; i++ { - sb.WriteString("\u2800\n") - } - } - - sb.WriteString("```\n") - - if isBackground { - sb.WriteString("\u21A9\uFE0F Reply to intervene") - } - - return sb.String() -} - -func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) { - if reasoningContent == "" || channelName == "" || channelID == "" { - return - } - - // Check context cancellation before attempting to publish, - - // since PublishOutbound's select may race between send and ctx.Done(). - - if ctx.Err() != nil { - return - } - - // Use a short timeout so the goroutine does not block indefinitely when - - // the outbound bus is full. Reasoning output is best-effort; dropping it - - // is acceptable to avoid goroutine accumulation. - - pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) - - defer pubCancel() - - if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channelName, - - ChatID: channelID, - - Content: reasoningContent, - }); err != nil { - // Treat context.DeadlineExceeded / context.Canceled as expected - - // (bus full under load, or parent canceled). Check the error - - // itself rather than ctx.Err(), because pubCtx may time out - - // (5 s) while the parent ctx is still active. - - // Also treat ErrBusClosed as expected — it occurs during normal - - // shutdown when the bus is closed before all goroutines finish. - - if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || - - errors.Is(err, bus.ErrBusClosed) { - logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ - "channel": channelName, - - "error": err.Error(), - }) - } else { - logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ - "channel": channelName, - - "error": err.Error(), - }) - } - } -} - -// streamingReasoningLines is the number of lines reserved for reasoning - -// in the streaming display. The remaining lines go to content. - -const streamingReasoningLines = 6 - -// buildStreamingDisplay builds a fixed-height status bubble for streaming. - -// - -// Layout when reasoning is active (reasoning only or both): - -// - -// 🧠 Thinking... - -// ━━━━━━━━━━ - -// <reasoning tail — streamingReasoningLines lines> - -// ━━━━━━━━━━ - -// <content tail — remaining lines> (or blank if content is empty) - -// █ - -// - -// Layout when no reasoning (content only): - -// - -// <content tail — streamingDisplayLines lines> - -// █ - -func buildStreamingDisplay(content, reasoning string) string { - if reasoning == "" { - // No reasoning — full window for content. - - return utils.TailPad(content, streamingDisplayLines, maxEntryLineWidth) + " \u2589" - } - - var sb strings.Builder - - // Header - - if content == "" { - sb.WriteString("\U0001f9e0 Thinking...\n") - } else { - sb.WriteString("\U0001f9e0 Thought, now responding...\n") - } - - sb.WriteString(statusSeparator) - - // Reasoning window - - headerLines := 2 // header + separator - - footerLines := 1 // separator before content - - contentLines := streamingDisplayLines - headerLines - footerLines - streamingReasoningLines - - if contentLines < 3 { - contentLines = 3 - } - - rLines := streamingDisplayLines - headerLines - footerLines - contentLines - - sb.WriteString(utils.TailPad(reasoning, rLines, maxEntryLineWidth)) - - sb.WriteByte('\n') - - sb.WriteString(statusSeparator) - - // Content window (may be blank padding if content hasn't started) - - sb.WriteString(utils.TailPad(content, contentLines, maxEntryLineWidth)) - - sb.WriteString(" \u2589") - - return sb.String() -} - -// runLLMIteration executes the LLM call loop with tool handling. - -// consumeStreamWithRepetitionDetection reads StreamEvents from ch, accumulates - -// content and tool calls, and runs repetition detection every checkInterval runes. - -// If repetition is detected, cancelFn is called to abort the HTTP request and - -// the function returns the partial response with detected=true. - -func consumeStreamWithRepetitionDetection( - ch <-chan protocoltypes.StreamEvent, - - cancelFn context.CancelFunc, - - checkInterval int, - - onChunk func(content, reasoning string), -) (*providers.LLMResponse, bool, error) { - var content strings.Builder - - var reasoning strings.Builder - - var toolCalls []streamToolCallAcc - - var finishReason string - - var usage *providers.UsageInfo - - runesSinceLastCheck := 0 - - for ev := range ch { - if ev.Err != nil { - return nil, false, ev.Err - } - - updated := false - - if ev.ContentDelta != "" { - content.WriteString(ev.ContentDelta) - - runesSinceLastCheck += utf8.RuneCountInString(ev.ContentDelta) - - updated = true - } - - if ev.ReasoningDelta != "" { - reasoning.WriteString(ev.ReasoningDelta) - - updated = true - } - - if updated && onChunk != nil { - onChunk(content.String(), reasoning.String()) - } - - if ev.FinishReason != "" { - finishReason = ev.FinishReason - } - - if ev.Usage != nil { - usage = ev.Usage - } - - for _, tc := range ev.ToolCallDeltas { - for len(toolCalls) <= tc.Index { - toolCalls = append(toolCalls, streamToolCallAcc{}) - } - - if tc.ID != "" { - toolCalls[tc.Index].id = tc.ID - } - - if tc.Name != "" { - toolCalls[tc.Index].name = tc.Name - } - - toolCalls[tc.Index].args.WriteString(tc.ArgumentsDelta) - } - - // Run repetition detection periodically on accumulated content. - - if runesSinceLastCheck >= checkInterval && content.Len() > 2000 { - runesSinceLastCheck = 0 - - if utils.DetectRepetitionLoop(content.String()) { - cancelFn() - - // Drain remaining events so the producer goroutine can exit. - - for range ch { - } - - resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage) - - return resp, true, nil - } - } - } - - resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage) - - return resp, false, nil -} - -// streamToolCallAcc accumulates streamed tool call fragments. - -type streamToolCallAcc struct { - id string - - name string - - args strings.Builder -} - -// buildAccumulatedResponse constructs an LLMResponse from accumulated stream data. - -func buildAccumulatedResponse( - content, reasoning string, - - toolCalls []streamToolCallAcc, - - finishReason string, - - usage *providers.UsageInfo, -) *providers.LLMResponse { - resp := &providers.LLMResponse{ - Content: content, - - Reasoning: reasoning, - - FinishReason: finishReason, - - Usage: usage, - } - - for _, tc := range toolCalls { - arguments := make(map[string]any) - - argStr := tc.args.String() - - if argStr != "" { - if err := json.Unmarshal([]byte(argStr), &arguments); err != nil { - arguments["raw"] = argStr - } - } - - resp.ToolCalls = append(resp.ToolCalls, providers.ToolCall{ - ID: tc.id, - - Name: tc.name, - - Arguments: arguments, - }) - } - - return resp -} - +// runLLMIteration executes the LLM call loop with tool handling using hooks. func (al *AgentLoop) runLLMIteration( ctx context.Context, - agent *AgentInstance, - messages []providers.Message, - opts processOptions, - task *activeTask, - planSnapshot string, ) (string, int, error) { - iteration := 0 + hooks := al.buildHooks(agent, opts, task, planSnapshot) + iteration := 0 var finalContent string - lastReminderIdx := -1 - - planMarkNudged := false // true after we've already nudged once for [x] marking - - maxIter := agent.MaxIterations - - // Snapshot unchecked step count before tool loop so we can detect progress. - - preUnchecked := -1 // -1 = not tracking - - if planSnapshot == "executing" { - preUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]") - } - - // Determine if this is a background task (cron, heartbeat, etc.) - - isBackground := opts.TaskID != "" - - for iteration < maxIter { + for iteration < agent.MaxIterations { iteration++ - // Update active task iteration - - if task != nil { - task.mu.Lock() - - task.Iteration = iteration - - task.mu.Unlock() - } - - // Check for user intervention via interrupt channel - - if task != nil { - select { - case msg := <-task.interrupt: - - messages = append(messages, providers.Message{ - Role: "user", - - Content: "[User Intervention] " + msg, - }) - - logger.InfoCF("agent", "User intervention injected", - - map[string]any{"agent_id": agent.ID, "iteration": iteration}) - - default: - } + if msg := hooks.OnIterationStart(iteration); msg != "" { + messages = append(messages, providers.Message{Role: "user", Content: msg}) } logger.DebugCF("agent", "LLM iteration", - map[string]any{ - "agent_id": agent.ID, - + "agent_id": agent.ID, "iteration": iteration, - - "max": maxIter, + "max": agent.MaxIterations, }) // Build tool definitions + providerToolDefs := hooks.FilterTools(agent.Tools.ToProviderDefs()) - providerToolDefs := agent.Tools.ToProviderDefs() - - // Interview mode: strip tool definitions the LLM must not use, - - // reducing token cost and preventing wasted reject-retry cycles. - - if isPlanPreExecution(planSnapshot) { - providerToolDefs = filterInterviewTools(providerToolDefs) + // Resolve model and candidates for this call + candidates := agent.Candidates + activeModel := agent.Model + if m, c := hooks.SelectModel(); m != "" { + activeModel = m + candidates = c } // Log LLM request details - logger.DebugCF("agent", "LLM request", - map[string]any{ - "agent_id": agent.ID, - - "iteration": iteration, - - "model": agent.Model, - - "messages_count": len(messages), - - "tools_count": len(providerToolDefs), - - "max_tokens": agent.MaxTokens, - - "temperature": agent.Temperature, - + "agent_id": agent.ID, + "iteration": iteration, + "model": activeModel, + "messages_count": len(messages), + "tools_count": len(providerToolDefs), + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, "system_prompt_len": len(messages[0].Content), }) - - // Log full messages (detailed) - logger.DebugCF("agent", "Full LLM request", - map[string]any{ - "iteration": iteration, - + "iteration": iteration, "messages_json": formatMessagesForLog(messages), - - "tools_json": formatToolsForLog(providerToolDefs), + "tools_json": formatToolsForLog(providerToolDefs), }) - // Call LLM with fallback chain if candidates are configured. + // Streaming setup + onChunk, streamCleanup := hooks.SetupStreaming() - var response *providers.LLMResponse + hooks.OnPreLLMCall() - var err error - - // Build onChunk callback for streaming preview. - - // Instead of a fixed-interval throttle, use a Go channel with - - // latest-value semantics: a consumer goroutine publishes status - - // updates as fast as the bus → manager → channel pipeline allows. - - // Backpressure is provided naturally by the per-channel rate limiter - - // (e.g. 20 msg/s for Telegram's SendDraft, 1 msg/s for Discord's EditMessage). - - type streamUpdate struct{ accumulated, reasoning string } - - var onChunk func(string, string) - - var streamCh chan streamUpdate - - var streamDone chan struct{} - - if !constants.IsInternalChannel(opts.Channel) { - streamCh = make(chan streamUpdate, 1) - - streamDone = make(chan struct{}) - - go func() { - defer close(streamDone) - - for up := range streamCh { - display := buildStreamingDisplay(up.accumulated, up.reasoning) - - outMsg := bus.OutboundMessage{ - Channel: opts.Channel, - - ChatID: opts.ChatID, - - Content: display, - } - - // For background tasks, publish streaming preview as - - // IsTaskStatus so it shares the same bubble as task - - // progress/completion (avoids a second bubble). - - if opts.Background && opts.TaskID != "" { - outMsg.IsTaskStatus = true - - outMsg.TaskID = opts.TaskID - } else { - outMsg.IsStatus = true - } - - _ = al.bus.PublishOutbound(ctx, outMsg) - } - }() - - onChunk = func(accumulated, reasoning string) { - if task != nil { - task.streamedChunks = true - } - - up := streamUpdate{accumulated, reasoning} - - // Non-blocking latest-value send: if the consumer hasn't - - // drained the previous update, replace it with the latest. - - select { - case streamCh <- up: - - default: - - // Channel full — drain stale value, then send latest. - - select { - case <-streamCh: - - default: - } - - select { - case streamCh <- up: - - default: - } - } - } - } - - // doCall invokes a single LLM provider, using streaming with - - // early repetition detection when the provider supports it. - - opts_ := map[string]any{ - "max_tokens": agent.MaxTokens, - - "temperature": agent.Temperature, - - "prompt_cache_key": agent.ID, - } - - doCall := func(ctx context.Context, p providers.LLMProvider, model string) (*providers.LLMResponse, error) { - if sp, ok := p.(providers.StreamingProvider); ok && sp.CanStream() { - streamCtx, streamCancel := context.WithCancel(ctx) - - defer streamCancel() - - ch, sErr := sp.ChatStream(streamCtx, messages, providerToolDefs, model, opts_) - - if sErr != nil { - return nil, sErr - } - - resp, repetition, sErr := consumeStreamWithRepetitionDetection(ch, streamCancel, 1000, onChunk) - - if sErr != nil { - return nil, sErr - } - - if repetition { - resp.FinishReason = "repetition_detected" - } - - return resp, nil - } - - return p.Chat(ctx, messages, providerToolDefs, model, opts_) - } - - callLLM := func() (*providers.LLMResponse, error) { - // Plan model switching: use plan model during interviewing/review phases - - candidates := agent.Candidates - - primaryModel := agent.Model - - if isPlanPreExecution(planSnapshot) && agent.PlanModel != "" { - candidates = agent.PlanCandidates - - primaryModel = agent.PlanModel - - logger.InfoCF("agent", "Using plan model", - - map[string]any{"agent_id": agent.ID, "plan_model": agent.PlanModel}) - } - - if len(candidates) > 1 && al.fallback != nil { - fbResult, fbErr := al.fallback.Execute(ctx, candidates, - - func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - p := al.resolveProvider(provider, model, agent.Provider) - - return doCall(ctx, p, model) - }, - ) - - if fbErr != nil { - return nil, fbErr - } - - if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { - logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", - - fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), - - map[string]any{"agent_id": agent.ID, "iteration": iteration}) - } - - return fbResult.Response, nil - } - - if len(candidates) > 0 { - c := candidates[0] - - p := al.resolveProvider(c.Provider, c.Model, agent.Provider) - - return doCall(ctx, p, c.Model) - } - - return doCall(ctx, agent.Provider, primaryModel) - } - - // Report waiting state to canvas before each LLM call. - - al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "") - - // Retry loop for context/token errors - - maxRetries := 2 - - for retry := 0; retry <= maxRetries; retry++ { - response, err = callLLM() - - if err == nil { - break - } - - errMsg := strings.ToLower(err.Error()) - - // Check if this is a network/HTTP timeout — not a context window error. - - isTimeoutError := errors.Is(err, context.DeadlineExceeded) || - - strings.Contains(errMsg, "deadline exceeded") || - - strings.Contains(errMsg, "client.timeout") || - - strings.Contains(errMsg, "timed out") || - - strings.Contains(errMsg, "timeout exceeded") - - // Detect real context window / token limit errors, excluding network timeouts. - - isContextError := !isTimeoutError && (strings.Contains(errMsg, "context_length_exceeded") || - - strings.Contains(errMsg, "context window") || - - strings.Contains(errMsg, "maximum context length") || - - strings.Contains(errMsg, "token limit") || - - strings.Contains(errMsg, "too many tokens") || - - strings.Contains(errMsg, "max_tokens") || - - strings.Contains(errMsg, "invalidparameter") || - - strings.Contains(errMsg, "prompt is too long") || - - strings.Contains(errMsg, "request too large")) - - if isTimeoutError && retry < maxRetries { - backoff := time.Duration(retry+1) * 5 * time.Second - - logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ - "error": err.Error(), - - "retry": retry, - - "backoff": backoff.String(), - }) - - time.Sleep(backoff) - - continue - } - - if isContextError && retry < maxRetries { - logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ - "error": err.Error(), - - "retry": retry, - }) - - if retry == 0 && !constants.IsInternalChannel(opts.Channel) { - _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - - ChatID: opts.ChatID, - - Content: "Context window exceeded. Compressing history and retrying...", - }) - } - - al.forceCompression(agent, opts.SessionKey) - - newHistory := agent.Sessions.GetHistory(opts.SessionKey) - - newSummary := agent.Sessions.GetSummary(opts.SessionKey) - - messages = agent.ContextBuilder.BuildMessages( - - newHistory, newSummary, "", - - nil, opts.Channel, opts.ChatID, - ) - - continue - } - - break - } - - // Streaming finished — close the stream goroutine so it flushes - - // the last update and exits cleanly before we process the response. - - if streamDone != nil { - // onChunk is captured by doCall closures; nil it to avoid - - // writes after the channel is closed during retries. + // Call LLM with retry + response, err := al.callLLMWithRetry(ctx, agent, &messages, opts, + providerToolDefs, candidates, activeModel, onChunk, iteration) + // Streaming cleanup + if streamCleanup != nil { onChunk = nil - - close(streamCh) - - <-streamDone - - streamDone = nil + streamCleanup() } if err != nil { logger.ErrorCF("agent", "LLM call failed", - map[string]any{ - "agent_id": agent.ID, - + "agent_id": agent.ID, "iteration": iteration, - - "error": err.Error(), + "error": err.Error(), }) - return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) } // Record token usage - if response.Usage != nil && al.stats != nil { al.stats.RecordUsage( - response.Usage.PromptTokens, - response.Usage.CompletionTokens, - response.Usage.TotalTokens, ) } - // Handle reasoning output (best-effort, non-blocking) - go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel)) logger.DebugCF("agent", "LLM response", - map[string]any{ - "agent_id": agent.ID, - - "iteration": iteration, - - "content_chars": len(response.Content), - - "tool_calls": len(response.ToolCalls), - - "reasoning": response.Reasoning, - + "agent_id": agent.ID, + "iteration": iteration, + "content_chars": len(response.Content), + "tool_calls": len(response.ToolCalls), + "reasoning": response.Reasoning, "target_channel": al.targetReasoningChannelID(opts.Channel), - - "channel": opts.Channel, + "channel": opts.Channel, }) - // Detect repetition loop on raw text (before stripping think - - // blocks so loops inside <think> are caught). Skip when the - - // provider already returned native tool calls. - - // Streaming providers may have already flagged repetition via - - // FinishReason="repetition_detected" — honor that too. - - if response.FinishReason == "repetition_detected" || - - (len(response.ToolCalls) == 0 && utils.DetectRepetitionLoop(response.Content)) { - logger.WarnCF("agent", "Repetition loop detected in LLM response, retrying", - - map[string]any{ - "agent_id": agent.ID, - - "iteration": iteration, - - "finish_reason": response.FinishReason, - - "content_length": len(response.Content), - }) - - // Retry once: inject nudge message and re-call - - savedMsgs := messages - - messages = append(append([]providers.Message(nil), messages...), - - providers.Message{ - Role: "user", - - Content: "[System] Your previous response contained degenerate repetition and was discarded. Please respond normally without repeating yourself.", - }) - - response, err = callLLM() - - messages = savedMsgs // restore original messages - - if err != nil { - return "", iteration, fmt.Errorf("LLM retry after repetition failed: %w", err) - } - - // Re-check on raw text; if still repeating give up - - if utils.DetectRepetitionLoop(response.Content) { - logger.ErrorCF("agent", "Repetition persists after retry, returning empty", - - map[string]any{"agent_id": agent.ID}) - - response.Content = "" - } - } - - // Strip think blocks before extracting XML tool calls so - - // extraction operates on clean content. - - response.Content = utils.StripThinkBlocks(response.Content) - - // Recover XML tool calls emitted as plain text by some providers. + // Clean up response content + response = al.cleanLLMResponse(ctx, response, &messages, agent, iteration, + providerToolDefs, candidates, activeModel, onChunk) + // No tool calls — check for plan nudge or return if len(response.ToolCalls) == 0 { - if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 { - response.ToolCalls = xmlCalls - } - } - - response.Content = providers.StripXMLToolCalls(response.Content) - - // Check if no tool calls - we're done - - if len(response.ToolCalls) == 0 { - // Plan continuation: if unchecked steps remain, nudge the LLM to - - // either mark completed steps or continue working on them. - - // This fires for both foreground and background plan execution, - - // ensuring the loop doesn't exit prematurely after marking a step. - - curUnchecked := 0 - - if preUnchecked > 0 { - curUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]") - } - - if curUnchecked > 0 && !planMarkNudged && - - planSnapshot == "executing" { - planMarkNudged = true - - messages = append(messages, providers.Message{ - Role: "assistant", - - Content: response.Content, - }) - - var nudgeMsg string - - if curUnchecked == preUnchecked { - nudgeMsg = fmt.Sprintf("[System] %d unchecked steps remain in MEMORY.md and "+ - - "none were marked [x] during this session. "+ - - "If you completed any steps, use edit_file to mark them [x] now. "+ - - "If steps are still in progress, continue working on them.", curUnchecked) - } else { - nudgeMsg = fmt.Sprintf("[System] Progress recorded. %d unchecked steps remain. "+ - - "Continue working on the next step.", curUnchecked) - } - - messages = append(messages, providers.Message{ - Role: "user", - - Content: nudgeMsg, - }) - - logger.InfoCF("agent", "Nudging plan execution: continue plan steps", - - map[string]any{"agent_id": agent.ID, "iteration": iteration, "unchecked": curUnchecked}) - + if nudge, cont := hooks.OnNoToolCalls(response.Content, iteration); cont { + messages = append(messages, + providers.Message{Role: "assistant", Content: response.Content}, + providers.Message{Role: "user", Content: nudge}, + ) continue } finalContent = response.Content - + if finalContent == "" && response.ReasoningContent != "" { + finalContent = response.ReasoningContent + } logger.InfoCF("agent", "LLM response without tool calls (direct answer)", - map[string]any{ - "agent_id": agent.ID, - - "iteration": iteration, - + "agent_id": agent.ID, + "iteration": iteration, "content_chars": len(finalContent), }) - break } + // Normalize and filter tool calls normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) } - // --- Interview mode: reject disallowed tool calls before they - - // enter messages or session history. Rejected calls are stripped - - // from normalizedToolCalls so they never reach the assistant - - // message, the tool-result list, or the session store. - - // A single compact rejection message is injected instead. - - var interviewRejected []string - - if isPlanPreExecution(planSnapshot) { - allowed := normalizedToolCalls[:0] // reuse backing array - - for _, tc := range normalizedToolCalls { - if isToolAllowedDuringInterview(tc.Name, tc.Arguments) { - allowed = append(allowed, tc) - } else { - interviewRejected = append(interviewRejected, tc.Name) - } - } - - normalizedToolCalls = allowed - - if len(interviewRejected) > 0 { - logger.InfoCF("agent", "Interview mode: rejected tool calls", - - map[string]any{ - "agent_id": agent.ID, - - "rejected": interviewRejected, - }) - - messages = append(messages, providers.Message{ - Role: "user", - - Content: interviewRejectMessage, - }) - } - - // If all tool calls were rejected, skip to next iteration. - - if len(normalizedToolCalls) == 0 { - continue - } + filtered, rejMsg := hooks.FilterToolCalls(normalizedToolCalls) + if len(filtered) < len(normalizedToolCalls) && rejMsg != "" { + messages = append(messages, providers.Message{Role: "user", Content: rejMsg}) + } + normalizedToolCalls = filtered + if len(normalizedToolCalls) == 0 { + continue } // Log tool calls - toolNames := make([]string, 0, len(normalizedToolCalls)) - for _, tc := range normalizedToolCalls { toolNames = append(toolNames, tc.Name) } - logger.InfoCF("agent", "LLM requested tool calls", - map[string]any{ - "agent_id": agent.ID, - - "tools": toolNames, - - "count": len(normalizedToolCalls), - + "agent_id": agent.ID, + "tools": toolNames, + "count": len(normalizedToolCalls), "iteration": iteration, }) - // Publish rich status update - - if !constants.IsInternalChannel(opts.Channel) && task != nil { - // Add pending entries to tool log for the current tool calls - - task.mu.Lock() - - for _, tc := range normalizedToolCalls { - task.toolLog = append(task.toolLog, toolLogEntry{ - Name: fmt.Sprintf("[%d] %s", iteration, tc.Name), - - ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace), - - Result: "\u23F3", - }) - - // Detect project directory - - if task.projectDir == "" && tc.Name == "exec" { - task.projectDir = extractExecProjectDir(tc.Arguments) - } - - switch tc.Name { - case "read_file", "write_file", "edit_file", "append_file", "list_dir": - - if p, _ := tc.Arguments["path"].(string); p != "" { - if rel := fileParentRelDir(p, agent.Workspace); rel != "" { - if task.fileCommonDir == "" { - task.fileCommonDir = rel - } else { - task.fileCommonDir = commonDirPrefix(task.fileCommonDir, rel) - } - } - } - } - } - - task.mu.Unlock() - - statusContent := buildRichStatus(task, isBackground, agent.Workspace) - - if isBackground { - _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - - ChatID: opts.ChatID, - - Content: statusContent, - - IsTaskStatus: true, - - TaskID: opts.TaskID, - }) - } else { - _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - - ChatID: opts.ChatID, - - Content: statusContent, - - IsStatus: true, - }) - } - } - - // Record session activity for heartbeat/plan coordination - - for _, tc := range normalizedToolCalls { - var detectedDir string - - if tc.Name == "exec" { - detectedDir = extractExecProjectDir(tc.Arguments) - } - - if detectedDir == "" { - switch tc.Name { - case "read_file", "write_file", "edit_file", "append_file", "list_dir": - - if p, _ := tc.Arguments["path"].(string); p != "" { - detectedDir = fileParentRelDir(p, agent.Workspace) - } - } - } - - if detectedDir != "" { - meta := &TouchMeta{ - ProjectPath: agent.ContextBuilder.GetPlanWorkDir(), - - Purpose: utils.Truncate(opts.UserMessage, 80), - - Branch: agent.GetWorktreeBranch(opts.SessionKey), - } - - if meta.ProjectPath == "" { - meta.ProjectPath = agent.Workspace - } - - al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir, meta) - } - } - - // Build assistant message with tool calls - - assistantMsg := providers.Message{ - Role: "assistant", - - Content: response.Content, - - ReasoningContent: response.ReasoningContent, - } - - for _, tc := range normalizedToolCalls { - // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3 - - extraContent := tc.ExtraContent - - thoughtSignature := "" - - if tc.Function != nil { - thoughtSignature = tc.Function.ThoughtSignature - } - - assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ - ID: tc.ID, - - Type: "function", - - Name: tc.Name, - - Arguments: tc.Arguments, - - Function: &providers.FunctionCall{ - Name: tc.Name, - - Arguments: tc.Arguments, - - ThoughtSignature: thoughtSignature, - }, - - ExtraContent: extraContent, - - ThoughtSignature: thoughtSignature, - }) - } + hooks.OnToolsProcessed(ctx, iteration, normalizedToolCalls) + // Build and save assistant message + assistantMsg := buildAssistantMessage(response, normalizedToolCalls) messages = append(messages, assistantMsg) - - // Save assistant message with tool calls to session - agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg) - // Execute tool calls + // Execute tool calls and collect results + lastBlocker := al.executeToolCalls(ctx, agent, normalizedToolCalls, &messages, opts, hooks, iteration) - var lastBlocker string - - for tcIdx, tc := range normalizedToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) - - argsPreview := utils.Truncate(string(argsJSON), 200) - - logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), - - map[string]any{ - "agent_id": agent.ID, - - "tool": tc.Name, - - "iteration": iteration, - }) - - // Heartbeat lazy worktree: create worktree on first write-tool call - - if opts.Background && isWriteTool(tc.Name) && !agent.IsInWorktree(opts.SessionKey) { - taskName := "heartbeat-" + time.Now().Format("20060102") - - hbDir := agent.ContextBuilder.GetPlanWorkDir() - - if wt, err := agent.ActivateWorktree(opts.SessionKey, taskName, hbDir); err == nil { - logger.InfoCF("agent", "Heartbeat worktree created", map[string]any{"branch": wt.Branch}) - } - } - - // Create async callback for tools that implement AsyncTool. - - // The callback publishes a system inbound message so processSystemMessage - - // injects the result into the conductor's session history. The conductor - - // sees it on its next turn and decides whether to notify the user. - - toolName := tc.Name // capture for goroutine - - asyncCallback := func(callbackCtx context.Context, result *tools.ToolResult) { - content := result.ForLLM - - if content == "" { - content = result.ForUser - } - - if content == "" { - return - } - - logger.InfoCF("agent", "Async tool completed, publishing to conductor", - - map[string]any{ - "tool": toolName, - - "content_len": len(content), - - "is_error": result.IsError, - }) - - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - - defer pubCancel() - - _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ - Channel: "system", - - SenderID: fmt.Sprintf("async:%s", toolName), - - ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID), - - Content: fmt.Sprintf("Async tool '%s' completed.\n\nResult:\n%s", toolName, content), - }) - } - - // Report toolcall state to canvas. - - al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateToolCall, tc.Name) - - toolStart := time.Now() - - toolCtx := ctx - - if wt := agent.GetWorktree(opts.SessionKey); wt != nil { - toolCtx = tools.WithWorkspaceOverride(toolCtx, wt.Path) - - toolCtx = tools.WithWorktreeInfo(toolCtx, wt) - } - - toolResult := agent.Tools.ExecuteWithContext( - - toolCtx, - - tc.Name, - - tc.Arguments, - - opts.Channel, - - opts.ChatID, - - asyncCallback, - ) - - toolDuration := time.Since(toolStart) - - // Update tool log entry with result - - if task != nil { - task.mu.Lock() - - // Find the matching pending entry (added earlier in this iteration) - - logIdx := len(task.toolLog) - len(normalizedToolCalls) + tcIdx - - if logIdx >= 0 && logIdx < len(task.toolLog) { - if toolResult.IsError || toolResult.Err != nil { - task.toolLog[logIdx].Result = fmt.Sprintf("\u2717 %.1fs", toolDuration.Seconds()) - - // Extract error detail for block display - - if toolResult.Err != nil { - task.toolLog[logIdx].ErrDetail = utils.Truncate(toolResult.Err.Error(), 300) - } else if toolResult.ForLLM != "" { - // exec returns IsError with exit info in ForLLM, not Err - - // Show last few lines (stderr / exit code) - - lines := strings.Split(strings.TrimSpace(toolResult.ForLLM), "\n") - - start := len(lines) - 3 - - if start < 0 { - start = 0 - } - - task.toolLog[logIdx].ErrDetail = utils.Truncate( - - strings.Join(lines[start:], "\n"), 300) - } - - // Sticky error: remember most recent error for persistent display - - entry := task.toolLog[logIdx] - - task.lastError = &entry - } else { - task.toolLog[logIdx].Result = fmt.Sprintf("\u2713 %.1fs", toolDuration.Seconds()) - } - } - - task.mu.Unlock() - } - - // Send ForUser content to user immediately if not Silent - - if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { - _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - - ChatID: opts.ChatID, - - Content: toolResult.ForUser, - }) - - logger.DebugCF("agent", "Sent tool result to user", - - map[string]any{ - "tool": tc.Name, - - "content_len": len(toolResult.ForUser), - }) - } - - // If tool returned media refs, publish them as outbound media - - if len(toolResult.Media) > 0 && opts.SendResponse { - parts := make([]bus.MediaPart, 0, len(toolResult.Media)) - - for _, ref := range toolResult.Media { - part := bus.MediaPart{Ref: ref} - - // Populate metadata from MediaStore when available - - if al.mediaStore != nil { - if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil { - part.Filename = meta.Filename - - part.ContentType = meta.ContentType - - part.Type = inferMediaType(meta.Filename, meta.ContentType) - } - } - - parts = append(parts, part) - } - - al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ - Channel: opts.Channel, - - ChatID: opts.ChatID, - - Parts: parts, - }) - } - - // Determine content for LLM based on tool result - - contentForLLM := toolResult.ForLLM - - if contentForLLM == "" && toolResult.Err != nil { - contentForLLM = toolResult.Err.Error() - } - - // Track blockers for task reminder - - if toolResult.IsError || toolResult.Err != nil { - lastBlocker = contentForLLM - } - - toolResultMsg := providers.Message{ - Role: "tool", - - Content: contentForLLM, - - ToolCallID: tc.ID, - } - - messages = append(messages, toolResultMsg) - - // Save tool result message to session - - agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) - } - - // Trim tool log sliding window to prevent unbounded growth - - if task != nil { - task.mu.Lock() - - if len(task.toolLog) > maxToolLogEntries { - task.toolLog = task.toolLog[len(task.toolLog)-maxToolLogEntries:] - } - - task.mu.Unlock() - } - - // Inject ephemeral task reminder to prevent focus drift. - - // Remove previous reminder and re-append at the tail so it stays - - // close to the LLM's attention window. - - if shouldInjectReminder(iteration, agent.TaskReminderInterval) && !opts.NoHistory { - if lastReminderIdx >= 0 && lastReminderIdx < len(messages) { - messages = append(messages[:lastReminderIdx], messages[lastReminderIdx+1:]...) - } - - reminderMsg := buildTaskReminder(opts.UserMessage, lastBlocker) - - messages = append(messages, reminderMsg) - - lastReminderIdx = len(messages) - 1 - - logger.DebugCF("agent", "Injected task reminder", - - map[string]any{ - "agent_id": agent.ID, - - "iteration": iteration, - - "has_blocker": lastBlocker != "", - }) - } - - // Inject plan-mode reminder to keep AI focused on interview/review workflow. - - if iteration > 1 && isPlanPreExecution(planSnapshot) { - if reminder, ok := buildPlanReminder(planSnapshot); ok { - messages = append(messages, reminder) - - logger.DebugCF("agent", "Injected plan reminder", - - map[string]any{ - "agent_id": agent.ID, - - "iteration": iteration, - - "plan_status": planSnapshot, - }) - } - } - - // Inject orchestration nudge during plan execution to encourage spawn usage. - - if planSnapshot == "executing" && agent.Subagents != nil && agent.Subagents.Enabled { - if reminder, ok := buildOrchReminder(iteration); ok { - messages = append(messages, reminder) - - logger.DebugCF("agent", "Injected orchestration nudge", - - map[string]any{ - "agent_id": agent.ID, - - "iteration": iteration, - }) - } - } - - // Inject pending subagent questions/plan reviews for the conductor to answer. - - if agent.SubagentMgr != nil { - for _, q := range agent.SubagentMgr.PendingQuestions() { - var content string - - switch q.Type { - case "plan_review": - - content = fmt.Sprintf( - "[Subagent %s submitted a plan for review]:\n%s\nRespond using the review_subagent_plan tool with task_id=%q.", - q.TaskID, - q.Content, - q.TaskID, - ) - - default: - - content = fmt.Sprintf( - "[Subagent %s asks]: %s\nRespond using the answer_subagent tool with task_id=%q.", - q.TaskID, - q.Content, - q.TaskID, - ) - } - - messages = append(messages, providers.Message{ - Role: "user", - - Content: content, - }) - } - } - - // Refresh system prompt: tool execution may have changed workDir, - - // memory, plan status, etc. Update messages[0] so the next LLM - - // call sees the current state. - - if touchDir := al.sessions.GetTouchDir(opts.SessionKey); touchDir != "" { - agent.ContextBuilder.SetWorkDir(filepath.Join(agent.Workspace, touchDir)) - } - - if newPrompt := agent.ContextBuilder.BuildSystemPrompt(); len(messages) > 0 && - - messages[0].Content != newPrompt { - messages[0].Content = newPrompt - - al.lastSystemPrompt.Store(newPrompt) - - al.promptDirty.Store(false) - } + hooks.InjectReminders(iteration, &messages, lastBlocker) + hooks.RefreshSystemPrompt(messages) } - // If max iterations exhausted with tool calls still pending, - - // make one final LLM call without tools to force a text response. - - if finalContent == "" && iteration >= maxIter { - logger.WarnCF("agent", "Max iterations reached, forcing final response without tools", - - map[string]any{ - "agent_id": agent.ID, - - "iteration": iteration, - }) - - forceResp, forceErr := agent.Provider.Chat(ctx, messages, nil, agent.Model, map[string]any{ - "max_tokens": agent.MaxTokens, - - "temperature": agent.Temperature, - - "prompt_cache_key": agent.ID, - }) - - if forceErr == nil && forceResp.Content != "" { - finalContent = utils.StripThinkBlocks(forceResp.Content) - - if forceResp.Usage != nil && al.stats != nil { - al.stats.RecordUsage( - - forceResp.Usage.PromptTokens, - - forceResp.Usage.CompletionTokens, - - forceResp.Usage.TotalTokens, - ) - } - } + // Force a final text response if max iterations exhausted + if finalContent == "" && iteration >= agent.MaxIterations { + finalContent = al.forceTextResponse(ctx, agent, messages) } return finalContent, iteration, nil } +// callLLMWithRetry calls the LLM with streaming support, fallback chain, +// and retry logic for timeout and context window errors. +func (al *AgentLoop) callLLMWithRetry( + ctx context.Context, + agent *AgentInstance, + messages *[]providers.Message, + opts processOptions, + toolDefs []providers.ToolDefinition, + candidates []providers.FallbackCandidate, + activeModel string, + onChunk func(string, string), + iteration int, +) (*providers.LLMResponse, error) { + llmOpts := map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID, + } + + // parseThinkingLevel guarantees ThinkingOff for empty/unknown values, + // so checking != ThinkingOff is sufficient. + if agent.ThinkingLevel != ThinkingOff { + if tc, ok := agent.Provider.(providers.ThinkingCapable); ok && tc.SupportsThinking() { + llmOpts["thinking_level"] = string(agent.ThinkingLevel) + } else { + logger.WarnCF("agent", "thinking_level is set but current provider does not support it, ignoring", + map[string]any{"agent_id": agent.ID, "thinking_level": string(agent.ThinkingLevel)}) + } + } + + doCall := func(ctx context.Context, p providers.LLMProvider, model string) (*providers.LLMResponse, error) { + if sp, ok := p.(providers.StreamingProvider); ok && sp.CanStream() { + streamCtx, streamCancel := context.WithCancel(ctx) + defer streamCancel() + ch, sErr := sp.ChatStream(streamCtx, *messages, toolDefs, model, llmOpts) + if sErr != nil { + return nil, sErr + } + resp, repetition, sErr := consumeStreamWithRepetitionDetection(ch, streamCancel, 1000, onChunk) + if sErr != nil { + return nil, sErr + } + if repetition { + resp.FinishReason = "repetition_detected" + } + return resp, nil + } + return p.Chat(ctx, *messages, toolDefs, model, llmOpts) + } + + callLLM := func() (*providers.LLMResponse, error) { + if len(candidates) > 1 && al.fallback != nil { + fbResult, fbErr := al.fallback.Execute(ctx, candidates, + func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { + p := al.resolveProvider(provider, model, agent.Provider) + return doCall(ctx, p, model) + }, + ) + if fbErr != nil { + return nil, fbErr + } + if fbResult.Provider != "" && len(fbResult.Attempts) > 0 { + logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts", + fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1), + map[string]any{"agent_id": agent.ID, "iteration": iteration}) + } + return fbResult.Response, nil + } + if len(candidates) > 0 { + c := candidates[0] + p := al.resolveProvider(c.Provider, c.Model, agent.Provider) + return doCall(ctx, p, c.Model) + } + return doCall(ctx, agent.Provider, activeModel) + } + + // Hook: pre-LLM state reporting (called via hooks in the caller) + + maxRetries := 2 + var response *providers.LLMResponse + var err error + + for retry := 0; retry <= maxRetries; retry++ { + response, err = callLLM() + if err == nil { + return response, nil + } + + 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, "maximum context length") || + strings.Contains(errMsg, "token limit") || + strings.Contains(errMsg, "too many tokens") || + strings.Contains(errMsg, "max_tokens") || + strings.Contains(errMsg, "invalidparameter") || + strings.Contains(errMsg, "prompt is too long") || + strings.Contains(errMsg, "request too large")) + + if isTimeoutError && retry < maxRetries { + backoff := time.Duration(retry+1) * 5 * time.Second + logger.WarnCF("agent", "Timeout error, retrying after backoff", map[string]any{ + "error": err.Error(), + "retry": retry, + "backoff": backoff.String(), + }) + time.Sleep(backoff) + continue + } + + if isContextError && retry < maxRetries { + logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{ + "error": err.Error(), + "retry": retry, + }) + if retry == 0 && !constants.IsInternalChannel(opts.Channel) { + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: "Context window exceeded. Compressing history and retrying...", + }) + } + al.forceCompression(agent, opts.SessionKey) + newHistory := agent.Sessions.GetHistory(opts.SessionKey) + newSummary := agent.Sessions.GetSummary(opts.SessionKey) + *messages = agent.ContextBuilder.BuildMessages( + newHistory, newSummary, "", + nil, opts.Channel, opts.ChatID, + ) + continue + } + break + } + return nil, err +} + +// cleanLLMResponse handles repetition detection, think block stripping, +// and XML tool call extraction on the raw LLM response. +func (al *AgentLoop) cleanLLMResponse( + ctx context.Context, + response *providers.LLMResponse, + messages *[]providers.Message, + agent *AgentInstance, + iteration int, + toolDefs []providers.ToolDefinition, + candidates []providers.FallbackCandidate, + activeModel string, + onChunk func(string, string), +) *providers.LLMResponse { + if response.FinishReason == "repetition_detected" || + (len(response.ToolCalls) == 0 && utils.DetectRepetitionLoop(response.Content)) { + logger.WarnCF("agent", "Repetition loop detected in LLM response, retrying", + map[string]any{ + "agent_id": agent.ID, + "iteration": iteration, + "finish_reason": response.FinishReason, + "content_length": len(response.Content), + }) + + savedMsgs := *messages + *messages = append(append([]providers.Message(nil), *messages...), + providers.Message{ + Role: "user", + Content: "[System] Your previous response contained degenerate repetition and was discarded. Please respond normally without repeating yourself.", + }) + + retryResp, retryErr := al.callLLMWithRetry(ctx, agent, messages, processOptions{}, + toolDefs, candidates, activeModel, onChunk, iteration) + *messages = savedMsgs + + if retryErr == nil { + response = retryResp + } + if utils.DetectRepetitionLoop(response.Content) { + logger.ErrorCF("agent", "Repetition persists after retry, returning empty", + map[string]any{"agent_id": agent.ID}) + response.Content = "" + } + } + + response.Content = utils.StripThinkBlocks(response.Content) + if len(response.ToolCalls) == 0 { + if xmlCalls := providers.ExtractXMLToolCalls(response.Content); len(xmlCalls) > 0 { + response.ToolCalls = xmlCalls + } + } + response.Content = providers.StripXMLToolCalls(response.Content) + return response +} + +// buildAssistantMessage constructs the assistant message with tool calls. +func buildAssistantMessage(response *providers.LLMResponse, toolCalls []providers.ToolCall) providers.Message { + msg := providers.Message{ + Role: "assistant", + Content: response.Content, + ReasoningContent: response.ReasoningContent, + } + for _, tc := range toolCalls { + extraContent := tc.ExtraContent + thoughtSignature := "" + if tc.Function != nil { + thoughtSignature = tc.Function.ThoughtSignature + } + msg.ToolCalls = append(msg.ToolCalls, providers.ToolCall{ + ID: tc.ID, + Type: "function", + Name: tc.Name, + Arguments: tc.Arguments, + Function: &providers.FunctionCall{ + Name: tc.Name, + Arguments: tc.Arguments, + ThoughtSignature: thoughtSignature, + }, + ExtraContent: extraContent, + ThoughtSignature: thoughtSignature, + }) + } + return msg +} + +// executeToolCalls runs each tool call sequentially, publishes results, +// and returns the last blocker (error content) for reminder injection. +func (al *AgentLoop) executeToolCalls( + ctx context.Context, + agent *AgentInstance, + toolCalls []providers.ToolCall, + messages *[]providers.Message, + opts processOptions, + hooks iterationHooks, + iteration int, +) string { + var lastBlocker string + for _, tc := range toolCalls { + argsJSON, _ := json.Marshal(tc.Arguments) + argsPreview := utils.Truncate(string(argsJSON), 200) + logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), + map[string]any{ + "agent_id": agent.ID, + "tool": tc.Name, + "iteration": iteration, + }) + + // Heartbeat lazy worktree: create worktree on first write-tool call + if opts.Background && isWriteTool(tc.Name) && !agent.IsInWorktree(opts.SessionKey) { + taskName := "heartbeat-" + time.Now().Format("20060102") + hbDir := agent.ContextBuilder.GetPlanWorkDir() + if wt, wtErr := agent.ActivateWorktree(opts.SessionKey, taskName, hbDir); wtErr == nil { + logger.InfoCF("agent", "Heartbeat worktree created", map[string]any{"branch": wt.Branch}) + } + } + + asyncCallback := hooks.OnPreToolExec(ctx, tc) + + toolStart := time.Now() + toolCtx := ctx + if wt := agent.GetWorktree(opts.SessionKey); wt != nil { + toolCtx = tools.WithWorkspaceOverride(toolCtx, wt.Path) + toolCtx = tools.WithWorktreeInfo(toolCtx, wt) + } + + toolResult := agent.Tools.ExecuteWithContext( + toolCtx, tc.Name, tc.Arguments, + opts.Channel, opts.ChatID, asyncCallback, + ) + toolDuration := time.Since(toolStart) + + hooks.OnToolExecDone(tc, toolResult, toolDuration) + + // Publish results to user + if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse { + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: toolResult.ForUser, + }) + logger.DebugCF("agent", "Sent tool result to user", + map[string]any{"tool": tc.Name, "content_len": len(toolResult.ForUser)}) + } + + if len(toolResult.Media) > 0 && opts.SendResponse { + al.publishToolMedia(ctx, toolResult, opts) + } + + // Build tool result message + contentForLLM := toolResult.ForLLM + if contentForLLM == "" && toolResult.Err != nil { + contentForLLM = toolResult.Err.Error() + } + if toolResult.IsError || toolResult.Err != nil { + lastBlocker = contentForLLM + } + + toolResultMsg := providers.Message{ + Role: "tool", + Content: contentForLLM, + ToolCallID: tc.ID, + } + *messages = append(*messages, toolResultMsg) + agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) + } + return lastBlocker +} + +// publishToolMedia publishes media refs from a tool result as outbound media. +func (al *AgentLoop) publishToolMedia(ctx context.Context, result *tools.ToolResult, opts processOptions) { + parts := make([]bus.MediaPart, 0, len(result.Media)) + for _, ref := range result.Media { + part := bus.MediaPart{Ref: ref} + if al.mediaStore != nil { + if _, meta, mErr := al.mediaStore.ResolveWithMeta(ref); mErr == nil { + part.Filename = meta.Filename + part.ContentType = meta.ContentType + part.Type = inferMediaType(meta.Filename, meta.ContentType) + } + } + parts = append(parts, part) + } + al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Parts: parts, + }) +} + +// forceTextResponse makes a final LLM call without tools when max iterations +// are exhausted, forcing a text response. +func (al *AgentLoop) forceTextResponse(ctx context.Context, agent *AgentInstance, messages []providers.Message) string { + logger.WarnCF("agent", "Max iterations reached, forcing final response without tools", + map[string]any{"agent_id": agent.ID}) + + forceResp, forceErr := agent.Provider.Chat(ctx, messages, nil, agent.Model, map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": agent.Temperature, + "prompt_cache_key": agent.ID, + }) + if forceErr != nil || forceResp.Content == "" { + return "" + } + content := utils.StripThinkBlocks(forceResp.Content) + if forceResp.Usage != nil && al.stats != nil { + al.stats.RecordUsage( + forceResp.Usage.PromptTokens, + forceResp.Usage.CompletionTokens, + forceResp.Usage.TotalTokens, + ) + } + return content +} + // updateToolContexts updates the context for tools that need channel/chatID info. func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) { @@ -4585,1938 +2558,3 @@ func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID st } } } - -// maybeSummarize triggers summarization if the session history exceeds thresholds. - -func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { - newHistory := agent.Sessions.GetHistory(sessionKey) - - tokenEstimate := al.estimateTokens(newHistory) - - threshold := agent.ContextWindow * 75 / 100 - - if len(newHistory) > 20 || tokenEstimate > threshold { - summarizeKey := agent.ID + ":" + sessionKey - - if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { - go func() { - defer al.summarizing.Delete(summarizeKey) - - logger.InfoCF("agent", "Memory threshold reached, optimizing conversation history", - - map[string]any{ - "session_key": sessionKey, - - "history_len": len(newHistory), - - "token_estimate": tokenEstimate, - }) - - al.summarizeSession(agent, sessionKey) - }() - } - } -} - -// forceCompression aggressively reduces context when the limit is hit. - -// It drops the oldest 50% of messages (keeping system prompt and last user message). - -func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { - history := agent.Sessions.GetHistory(sessionKey) - - if len(history) <= 4 { - return - } - - // Keep system prompt (usually [0]) and the very last message (user's trigger) - - // We want to drop the oldest half of the *conversation* - - // Assuming [0] is system, [1:] is conversation - - conversation := history[1 : len(history)-1] - - if len(conversation) == 0 { - return - } - - // Helper to find the mid-point of the conversation - - mid := len(conversation) / 2 - - // New history structure: - - // 1. System Prompt (with compression note appended) - - // 2. Second half of conversation - - // 3. Last message - - droppedCount := mid - - keptConversation := conversation[mid:] - - newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1) - - // Append compression note to the original system prompt instead of adding a new system message - - // This avoids having two consecutive system messages which some APIs (like Zhipu) reject - - compressionNote := fmt.Sprintf( - - "\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", - - droppedCount, - ) - - enhancedSystemPrompt := history[0] - - enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote - - newHistory = append(newHistory, enhancedSystemPrompt) - - newHistory = append(newHistory, keptConversation...) - - newHistory = append(newHistory, history[len(history)-1]) // Last message - - // Update session - - agent.Sessions.SetHistory(sessionKey, newHistory) - - agent.Sessions.Save(sessionKey) - - logger.WarnCF("agent", "Forced compression executed", map[string]any{ - "session_key": sessionKey, - - "dropped_msgs": droppedCount, - - "new_count": len(newHistory), - }) -} - -// GetStartupInfo returns information about loaded tools and skills for logging. - -func (al *AgentLoop) GetStartupInfo() map[string]any { - info := make(map[string]any) - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return info - } - - // Tools info - - toolsList := agent.Tools.List() - - toolsMap := map[string]any{ - "count": len(toolsList), - - "names": toolsList, - } - - // Report web search provider if registered - - if t, ok := agent.Tools.Get("web_search"); ok { - if wst, ok := t.(*tools.WebSearchTool); ok { - toolsMap["web_search_provider"] = wst.ProviderName() - } - } - - info["tools"] = toolsMap - - // Skills info - - info["skills"] = agent.ContextBuilder.GetSkillsInfo() - - // Agents info - - info["agents"] = map[string]any{ - "count": len(al.registry.ListAgentIDs()), - - "ids": al.registry.ListAgentIDs(), - } - - return info -} - -// ListSkills returns all available skills from the default agent. - -func (al *AgentLoop) ListSkills() []skills.SkillInfo { - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return nil - } - - return agent.ContextBuilder.ListSkills() -} - -// GetPlanInfo returns plan state from the default agent's memory store. - -func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string, memory string) { - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return false, "", 0, 0, "No agent available.", "" - } - - mem := agent.ContextBuilder.Memory() - - if mem == nil { - return false, "", 0, 0, "No memory store.", "" - } - - hasPlan = mem.HasActivePlan() - - status = mem.GetPlanStatus() - - currentPhase = mem.GetCurrentPhase() - - totalPhases = mem.GetTotalPhases() - - display = mem.FormatPlanDisplay() - - memory = mem.ReadLongTerm() - - return hasPlan, status, currentPhase, totalPhases, display, memory -} - -// GetPlanStatus returns the current plan status ("interviewing", "executing", "review", etc.) or "". - -func (al *AgentLoop) GetPlanStatus() string { - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return "" - } - - return agent.ContextBuilder.GetPlanStatus() -} - -// GetPlanPhases returns structured phase/step data from the default agent's plan. - -func (al *AgentLoop) GetPlanPhases() []PlanPhase { - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return nil - } - - mem := agent.ContextBuilder.Memory() - - if mem == nil { - return nil - } - - return mem.GetPlanPhases() -} - -// GetActiveSessions returns currently active sessions for the mini app API. - -func (al *AgentLoop) GetActiveSessions() []SessionEntry { - return al.sessions.ListActive() -} - -// GetSessionStats returns the current session statistics snapshot, or nil if stats tracking is disabled. - -func (al *AgentLoop) GetSessionStats() *stats.Stats { - if al.stats == nil { - return nil - } - - s := al.stats.GetStats() - - return &s -} - -// GetContextInfo returns the bootstrap file resolution and directory context for the default agent. - -func (al *AgentLoop) GetContextInfo() (workDir, planWorkDir, workspace string, bootstrap []BootstrapFileInfo) { - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return "", "", "", nil - } - - workspace = agent.Workspace - - planWorkDir = agent.ContextBuilder.GetPlanWorkDir() - - // Use the most recent active session's touch_dir (tool-detected project directory) - - if active := al.sessions.ListActive(); len(active) > 0 && active[0].TouchDir != "" { - workDir = active[0].TouchDir - } else { - workDir = agent.ContextBuilder.workDir - } - - bootstrap = agent.ContextBuilder.ResolveBootstrapPaths() - - return workDir, planWorkDir, workspace, bootstrap -} - -// GetSystemPrompt returns the system prompt last sent to the LLM. - -// If the prompt is dirty (state changed since last capture), it rebuilds - -// from current state. Falls back to building if no LLM call has occurred yet. - -func (al *AgentLoop) GetSystemPrompt() string { - if !al.promptDirty.Load() { - if v := al.lastSystemPrompt.Load(); v != nil { - return v.(string) - } - } - - // Rebuild from current state - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return "" - } - - prompt := agent.ContextBuilder.BuildSystemPrompt() - - al.lastSystemPrompt.Store(prompt) - - al.promptDirty.Store(false) - - return prompt -} - -// 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) - - args := tc.Arguments - - if len(args) == 0 && tc.Function != nil { - args = tc.Function.Arguments - } - - if len(args) > 0 { - argsJSON, _ := json.Marshal(args) - - fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(string(argsJSON), 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(string(tool.Function.Parameters), 200)) - } - } - - sb.WriteString("]") - - return sb.String() -} - -// summarizeSession summarizes the conversation history for a session. - -func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - - defer cancel() - - history := agent.Sessions.GetHistory(sessionKey) - - summary := agent.Sessions.GetSummary(sessionKey) - - // Keep last 4 messages for continuity - - if len(history) <= 4 { - return - } - - toSummarize := history[:len(history)-4] - - // Oversized Message Guard - - maxMessageTokens := agent.ContextWindow / 2 - - validMessages := make([]providers.Message, 0) - - omitted := false - - for _, m := range toSummarize { - if m.Role != "user" && m.Role != "assistant" { - continue - } - - msgTokens := len(m.Content) / 2 - - if msgTokens > maxMessageTokens { - omitted = true - - continue - } - - validMessages = append(validMessages, m) - } - - if len(validMessages) == 0 { - return - } - - // Multi-Part Summarization - - var finalSummary string - - if len(validMessages) > 10 { - mid := len(validMessages) / 2 - - part1 := validMessages[:mid] - - part2 := validMessages[mid:] - - s1, _ := al.summarizeBatch(ctx, agent, part1, "") - - s2, _ := al.summarizeBatch(ctx, agent, part2, "") - - mergePrompt := fmt.Sprintf( - - "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", - - s1, - - s2, - ) - - resp, err := agent.Provider.Chat( - - ctx, - - []providers.Message{{Role: "user", Content: mergePrompt}}, - - nil, - - agent.Model, - - map[string]any{ - "max_tokens": 1024, - - "temperature": 0.3, - - "prompt_cache_key": agent.ID, - }, - ) - - if err == nil { - finalSummary = resp.Content - } else { - finalSummary = s1 + " " + s2 - } - } else { - finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) - } - - if omitted && finalSummary != "" { - finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" - } - - if finalSummary != "" { - if err := agent.Sessions.CompactOldTurns(sessionKey, 4, finalSummary); err != nil { - logger.ErrorCF("agent", "CompactOldTurns failed, falling back", - - map[string]any{"error": err.Error()}) - - agent.Sessions.SetSummary(sessionKey, finalSummary) - - agent.Sessions.TruncateHistory(sessionKey, 4) - - agent.Sessions.Save(sessionKey) - } - } -} - -// summarizeBatch summarizes a batch of messages. - -func (al *AgentLoop) summarizeBatch( - ctx context.Context, - - agent *AgentInstance, - - batch []providers.Message, - - existingSummary string, -) (string, error) { - var sb strings.Builder - - sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") - - if agent.ContextBuilder.HasActivePlan() { - sb.WriteString("Note: Active plan in MEMORY.md. Preserve plan progress references.\n") - } - - if existingSummary != "" { - sb.WriteString("Existing context: ") - - sb.WriteString(existingSummary) - - sb.WriteString("\n") - } - - sb.WriteString("\nCONVERSATION:\n") - - for _, m := range batch { - fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) - } - - prompt := sb.String() - - response, err := agent.Provider.Chat( - - ctx, - - []providers.Message{{Role: "user", Content: prompt}}, - - nil, - - agent.Model, - - map[string]any{ - "max_tokens": 1024, - - "temperature": 0.3, - - "prompt_cache_key": agent.ID, - }, - ) - if err != nil { - return "", err - } - - return response.Content, nil -} - -// estimateTokens estimates the number of tokens in a message list. - -// Uses a safe heuristic of 2.5 characters per token to account for CJK and other - -// overheads better than the previous 3 chars/token. - -func (al *AgentLoop) estimateTokens(messages []providers.Message) int { - totalChars := 0 - - for _, m := range messages { - totalChars += utf8.RuneCountInString(m.Content) - } - - // 2.5 chars per token = totalChars * 2 / 5 - - return totalChars * 2 / 5 -} - -func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { - content := strings.TrimSpace(msg.Content) - - if !strings.HasPrefix(content, "/") { - return "", false - } - - parts := strings.Fields(content) - - if len(parts) == 0 { - return "", false - } - - cmd := parts[0] - - args := parts[1:] - - switch cmd { - case "/show": - - if len(args) < 1 { - return "Usage: /show [model|channel|agents]", true - } - - switch args[0] { - case "model": - - defaultAgent := al.registry.GetDefaultAgent() - - if defaultAgent == nil { - return "No default agent configured", true - } - - return fmt.Sprintf("Current model: %s", defaultAgent.Model), true - - case "channel": - - return fmt.Sprintf("Current channel: %s", msg.Channel), true - - case "agents": - - agentIDs := al.registry.ListAgentIDs() - - return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true - - default: - - return fmt.Sprintf("Unknown show target: %s", args[0]), true - } - - case "/list": - - if len(args) < 1 { - return "Usage: /list [models|channels|agents]", true - } - - switch args[0] { - case "models": - - return "Available models: configured in config.json per agent", true - - case "channels": - - if al.channelManager == nil { - return "Channel manager not initialized", true - } - - channels := al.channelManager.GetEnabledChannels() - - if len(channels) == 0 { - return "No channels enabled", true - } - - return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true - - case "agents": - - agentIDs := al.registry.ListAgentIDs() - - return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true - - default: - - return fmt.Sprintf("Unknown list target: %s", args[0]), true - } - - case "/switch": - - if len(args) < 3 || args[1] != "to" { - return "Usage: /switch [model|channel] to <name>", true - } - - target := args[0] - - value := args[2] - - switch target { - case "model": - - defaultAgent := al.registry.GetDefaultAgent() - - if defaultAgent == nil { - return "No default agent configured", true - } - - oldModel := defaultAgent.Model - - defaultAgent.Model = value - - return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true - - case "channel": - - if al.channelManager == nil { - return "Channel manager not initialized", true - } - - if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { - return fmt.Sprintf("Channel '%s' not found or not enabled", value), true - } - - return fmt.Sprintf("Switched target channel to %s", value), true - - default: - - return fmt.Sprintf("Unknown switch target: %s", target), true - } - - case "/session": - - return al.handleSessionCommand(args, msg.SessionKey), true - - case "/skills": - - return al.handleSkillsCommand(), true - - case "/plan": - - resp, handled := al.handlePlanCommand(args, msg.SessionKey) - - if handled { - al.notifyStateChange() - } - - return resp, handled - - case "/heartbeat": - - resp, handled := al.handleHeartbeatCommand(args, msg) - - if handled { - al.notifyStateChange() - } - - return resp, handled - } - - return "", false -} - -func (al *AgentLoop) handleHeartbeatCommand(args []string, msg bus.InboundMessage) (string, bool) { - if len(args) == 0 { - return "Usage: /heartbeat thread [here|off|<thread_id>]", true - } - - if args[0] != "thread" { - return "Usage: /heartbeat thread [here|off|<thread_id>]", true - } - - if len(args) < 2 { - return "Usage: /heartbeat thread [here|off|<thread_id>]", true - } - - if msg.Channel != "telegram" { - return "/heartbeat thread is only supported from Telegram chats.", true - } - - baseChatID, currentThreadID := splitChatAndThread(msg.ChatID) - - if baseChatID == "" { - return "Unable to detect Telegram chat ID for heartbeat routing.", true - } - - arg := strings.ToLower(strings.TrimSpace(args[1])) - - var threadID int - - var err error - - switch arg { - case "off", "disable", "clear": - - threadID = 0 - - case "here", "this": - - if currentThreadID <= 0 { - return "Current Telegram message is not in a thread. Usage: /heartbeat thread <thread_id>", true - } - - threadID = currentThreadID - - default: - - threadID, err = strconv.Atoi(arg) - - if err != nil || threadID < 0 { - return "Usage: /heartbeat thread [here|off|<thread_id>]", true - } - } - - al.cfg.Channels.Telegram.HeartbeatThreadID = threadID - - if al.state != nil { - _ = al.state.SetHeartbeatTarget(fmt.Sprintf("telegram:%s", baseChatID)) - } - - if al.onHeartbeatThreadUpdate != nil { - al.onHeartbeatThreadUpdate(threadID) - } - - if al.saveConfig != nil { - if err := al.saveConfig(al.cfg); err != nil { - return fmt.Sprintf("Failed to persist config.json: %v", err), true - } - } - - if threadID == 0 { - return fmt.Sprintf("Heartbeat thread routing disabled for chat %s and saved to config.json.", baseChatID), true - } - - return fmt.Sprintf("Heartbeat thread set to %d for chat %s and saved to config.json.", threadID, baseChatID), true -} - -func splitChatAndThread(chatID string) (baseChatID string, threadID int) { - baseChatID = strings.TrimSpace(chatID) - - if baseChatID == "" { - return "", 0 - } - - if slash := strings.Index(baseChatID, "/"); slash >= 0 { - threadPart := strings.TrimSpace(baseChatID[slash+1:]) - - baseChatID = strings.TrimSpace(baseChatID[:slash]) - - if tid, err := strconv.Atoi(threadPart); err == nil && tid > 0 { - threadID = tid - } - } - - return baseChatID, threadID -} - -// handleSessionCommand dispatches /session subcommands. - -func (al *AgentLoop) handleSessionCommand(args []string, sessionKey string) string { - sub := "" - - if len(args) > 0 { - sub = strings.ToLower(strings.TrimSpace(args[0])) - } - - switch sub { - case "list": - - return al.handleSessionList() - - case "graph": - - return al.handleSessionGraph() - - case "fork": - - return al.handleSessionFork(args[1:], sessionKey) - - case "reset": - - if al.stats == nil { - return "Stats tracking is disabled." - } - - al.stats.Reset() - - return "Session statistics have been reset." - - default: - - return al.handleSessionStats() - } -} - -func (al *AgentLoop) handleSessionStats() string { - agent := al.registry.GetDefaultAgent() - - store := agent.Sessions.Store() - - // Session DAG summary - - sessions, _ := store.List(nil) - - var sb strings.Builder - - fmt.Fprintf(&sb, "Sessions: %d in store\n", len(sessions)) - - if len(sessions) > 0 { - active, completed := 0, 0 - - for _, s := range sessions { - switch s.Status { - case "active": - - active++ - - case "completed": - - completed++ - } - } - - fmt.Fprintf(&sb, " active=%d completed=%d\n", active, completed) - } - - sb.WriteString("\nUse: /session list | graph | fork [label]\n") - - // Token stats if available - - if al.stats != nil { - s := al.stats.GetStats() - - fmt.Fprintf(&sb, - - "\nToken Stats — Today (%s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)\n"+ - - "All time (since %s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)", - - s.Today.Date, - - s.Today.Prompts, - - s.Today.Requests, - - stats.FormatTokenCount(s.Today.TotalTokens), - - stats.FormatTokenCount(s.Today.PromptTokens), - - stats.FormatTokenCount(s.Today.CompletionTokens), - - s.Since.Format("2006-01-02"), - - s.TotalPrompts, - - s.TotalRequests, - - stats.FormatTokenCount(s.TotalTokens), - - stats.FormatTokenCount(s.TotalPromptTokens), - - stats.FormatTokenCount(s.TotalCompletionTokens), - ) - } - - return sb.String() -} - -// shortSessionKey truncates long session keys for display. - -func shortSessionKey(key string) string { - parts := strings.Split(key, ":") - - if len(parts) > 2 { - return strings.Join(parts[2:], ":") - } - - return key -} - -func (al *AgentLoop) handleSessionList() string { - agent := al.registry.GetDefaultAgent() - - store := agent.Sessions.Store() - - sessions, err := store.List(nil) - if err != nil { - return fmt.Sprintf("Error listing sessions: %v", err) - } - - if len(sessions) == 0 { - return "No sessions in store." - } - - var sb strings.Builder - - fmt.Fprintf(&sb, "Sessions (%d)\n", len(sessions)) - - for _, s := range sessions { - age := time.Since(s.UpdatedAt).Truncate(time.Second) - - label := s.Label - - if label == "" { - label = shortSessionKey(s.Key) - } - - parent := "" - - if s.ParentKey != "" { - parent = " parent=" + shortSessionKey(s.ParentKey) - } - - fmt.Fprintf(&sb, "- %s [%s] (%s) turns=%d%s\n", - - label, s.Status, age, s.TurnCount, parent) - } - - return sb.String() -} - -func (al *AgentLoop) handleSessionGraph() string { - agent := al.registry.GetDefaultAgent() - - store := agent.Sessions.Store() - - sessions, err := store.List(nil) - if err != nil { - return fmt.Sprintf("Error listing sessions: %v", err) - } - - if len(sessions) == 0 { - return "No sessions in store." - } - - // Build parent→children map and find roots - - byKey := make(map[string]*session.SessionInfo, len(sessions)) - - children := make(map[string][]string) - - var roots []string - - for _, s := range sessions { - byKey[s.Key] = s - - if s.ParentKey == "" { - roots = append(roots, s.Key) - } else { - children[s.ParentKey] = append(children[s.ParentKey], s.Key) - } - } - - var sb strings.Builder - - sb.WriteString("Session Graph\n") - - for i, root := range roots { - last := i == len(roots)-1 - - printSessionTree(&sb, root, byKey, children, "", last) - } - - return sb.String() -} - -func printSessionTree( - sb *strings.Builder, - key string, - byKey map[string]*session.SessionInfo, - children map[string][]string, - prefix string, - last bool, -) { - s := byKey[key] - - if s == nil { - return - } - - connector := "├── " - - if last { - connector = "└── " - } - - icon := "●" - - if s.Status == "completed" { - icon = "✓" - } - - label := s.Label - - if label == "" { - label = shortSessionKey(s.Key) - } - - fmt.Fprintf(sb, "%s%s%s %s (turns=%d)\n", prefix, connector, icon, label, s.TurnCount) - - childPrefix := prefix + "│ " - - if last { - childPrefix = prefix + " " - } - - kids := children[key] - - for i, childKey := range kids { - printSessionTree(sb, childKey, byKey, children, childPrefix, i == len(kids)-1) - } -} - -func (al *AgentLoop) handleSessionFork(args []string, sessionKey string) string { - if sessionKey == "" { - return "Cannot fork: no active session key." - } - - agent := al.registry.GetDefaultAgent() - - store := agent.Sessions.Store() - - label := "fork" - - if len(args) > 0 { - label = strings.Join(args, " ") - } - - childKey := sessionKey + ":fork:" + time.Now().Format("20060102T150405") - - err := store.Fork(sessionKey, childKey, &session.CreateOpts{Label: label}) - if err != nil { - return fmt.Sprintf("Fork failed: %v", err) - } - - return fmt.Sprintf( - "Forked session\n parent: %s\n child: %s", - shortSessionKey(sessionKey), - shortSessionKey(childKey), - ) -} - -// SessionGraphNode represents a session node for the Mini App graph API. - -type SessionGraphNode struct { - Key string `json:"key"` - - Label string `json:"label"` - - Status string `json:"status"` - - Summary string `json:"summary"` - - ParentKey string `json:"parent_key"` - - ForkTurnID string `json:"fork_turn_id"` - - TurnCount int `json:"turn_count"` - - CreatedAt time.Time `json:"created_at"` - - UpdatedAt time.Time `json:"updated_at"` -} - -// GetSessionGraph returns all sessions as a flat list of graph nodes. - -func (al *AgentLoop) GetSessionGraph() []SessionGraphNode { - agent := al.registry.GetDefaultAgent() - - store := agent.Sessions.Store() - - sessions, err := store.List(nil) - if err != nil { - return nil - } - - nodes := make([]SessionGraphNode, 0, len(sessions)) - - for _, s := range sessions { - nodes = append(nodes, SessionGraphNode{ - Key: s.Key, - - Label: s.Label, - - Status: s.Status, - - Summary: s.Summary, - - ParentKey: s.ParentKey, - - ForkTurnID: s.ForkTurnID, - - TurnCount: s.TurnCount, - - CreatedAt: s.CreatedAt, - - UpdatedAt: s.UpdatedAt, - }) - } - - return nodes -} - -// expandSkillCommand detects "/skill <name> [message]" and returns: - -// - expanded: full content with SKILL.md injected (for LLM) - -// - compact: skill name tag + user message only (for history) - -// - ok: whether expansion happened - -func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) { - content := strings.TrimSpace(msg.Content) - - if !strings.HasPrefix(content, "/skill ") { - return "", "", false - } - - // Parse: /skill <name> [message] - - rest := strings.TrimSpace(content[7:]) // len("/skill ") == 7 - - parts := strings.SplitN(rest, " ", 2) - - if len(parts) == 0 || parts[0] == "" { - return "", "", false - } - - skillName := parts[0] - - userMessage := "" - - if len(parts) > 1 { - userMessage = strings.TrimSpace(parts[1]) - } - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return "", "", false - } - - skillContent, found := agent.ContextBuilder.LoadSkill(skillName) - - if !found { - return "", "", false - } - - tag := fmt.Sprintf("[Skill: %s]", skillName) - - // Build expanded message: skill instructions + user message (for LLM) - - var sb strings.Builder - - sb.WriteString(tag) - - sb.WriteString("\n\n") - - sb.WriteString(skillContent) - - if userMessage != "" { - sb.WriteString("\n\n---\n\n") - - sb.WriteString(userMessage) - } - - // Build compact form: skill name tag + user message only (for history) - - compactForm := tag - - if userMessage != "" { - compactForm = tag + "\n" + userMessage - } - - return sb.String(), compactForm, true -} - -// handleSkillsCommand lists all available skills. - -func (al *AgentLoop) handleSkillsCommand() string { - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return "No agent configured." - } - - skillsList := agent.ContextBuilder.ListSkills() - - if len(skillsList) == 0 { - return "No skills available.\nAdd skills to your workspace/skills/ directory." - } - - var sb strings.Builder - - sb.WriteString("Available Skills\n\n") - - for _, s := range skillsList { - fmt.Fprintf(&sb, "**%s** (%s)\n", s.Name, s.Source) - - if s.Description != "" { - fmt.Fprintf(&sb, "```\n%s\n```\n", s.Description) - } - } - - sb.WriteString("\nUse: /skill <name> [message]") - - return sb.String() -} - -// handlePlanCommand handles /plan subcommands that can be resolved instantly. - -// Returns (response, handled). For "/plan <task>" (new plan), it returns - -// ("", false) so the message falls through to the LLM queue, where - -// expandPlanCommand writes the seed and rewrites the content. - -func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string, bool) { - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return "No agent configured.", true - } - - if len(args) == 0 { - // /plan — show current plan - - return agent.ContextBuilder.FormatPlanDisplay(), true - } - - sub := args[0] - - switch sub { - case "clear": - - if agent.ContextBuilder.ReadMemory() == "" { - return "No active plan to clear.", true - } - - // Deactivate worktree on plan clear - - if sessionKey != "" { - agent.DeactivateWorktree(sessionKey, "", true) - } - - if err := agent.ContextBuilder.ClearMemory(); err != nil { - return fmt.Sprintf("Error clearing plan: %v", err), true - } - - return "Plan cleared.", true - - case "done": - - if !agent.ContextBuilder.HasActivePlan() { - return "No active plan.", true - } - - if len(args) < 2 { - return "Usage: /plan done <step number>", true - } - - stepNum, err := strconv.Atoi(args[1]) - - if err != nil || stepNum < 1 { - return "Step number must be a positive integer.", true - } - - phase := agent.ContextBuilder.GetCurrentPhase() - - if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil { - return fmt.Sprintf("Error: %v", err), true - } - - return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase), true - - case "add": - - if !agent.ContextBuilder.HasActivePlan() { - return "No active plan.", true - } - - if len(args) < 2 { - return "Usage: /plan add <step description>", true - } - - desc := strings.Join(args[1:], " ") - - phase := agent.ContextBuilder.GetCurrentPhase() - - if err := agent.ContextBuilder.AddStep(phase, desc); err != nil { - return fmt.Sprintf("Error: %v", err), true - } - - return fmt.Sprintf("Added step to phase %d: %s", phase, desc), true - - case "start": - - if !agent.ContextBuilder.HasActivePlan() { - return "No active plan.", true - } - - status := agent.ContextBuilder.GetPlanStatus() - - if status == "executing" { - return "Plan is already executing.", true - } - - if status != "interviewing" && status != "review" { - return fmt.Sprintf("Cannot start from status %q.", status), true - } - - if agent.ContextBuilder.GetTotalPhases() == 0 { - return "Cannot start: no phases defined yet. Complete the interview first.", true - } - - if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil { - return fmt.Sprintf("Error: %v", err), true - } - - al.reporter().ReportStateChange(sessionKey, orch.AgentStatePlanExecuting, "") - - al.planStartPending = true - - clearHistory := len(args) > 1 && args[1] == "clear" - - al.planClearHistory = clearHistory - - if clearHistory { - return "Plan approved. Executing with clean history.", true - } - - return "Plan approved. Executing.", true - - case "next": - - if !agent.ContextBuilder.HasActivePlan() { - return "No active plan.", true - } - - if err := agent.ContextBuilder.AdvancePhase(); err != nil { - return fmt.Sprintf("Error: %v", err), true - } - - phase := agent.ContextBuilder.GetCurrentPhase() - - return fmt.Sprintf("Advanced to phase %d.", phase), true - - case "worktrees": - - return al.handlePlanWorktreesCommand(agent, args[1:]), true - - default: - - // /plan <task description> — start new plan - - // Block if a plan is already active (fast-path error). - - if agent.ContextBuilder.HasActivePlan() { - return "A plan is already active. Use /plan clear first.", true - } - - // Not handled here — let the message flow to the LLM queue. - - // expandPlanCommand will write the seed and rewrite the content. - - return "", false - } -} - -// isPlanPreExecution returns true if the plan is in a pre-execution state - -// (interviewing or review) where tool restrictions and iteration caps apply. - -func (al *AgentLoop) handlePlanWorktreesCommand(agent *AgentInstance, args []string) string { - repoRoot := git.FindRepoRoot(agent.Workspace) - - if repoRoot == "" { - return "Workspace is not a git repository." - } - - worktreesDir := filepath.Join(agent.Workspace, ".worktrees") - - sub := "list" - - if len(args) > 0 { - sub = strings.ToLower(strings.TrimSpace(args[0])) - } - - switch sub { - case "", "list": - - items, err := git.ListManagedWorktrees(repoRoot, worktreesDir) - if err != nil { - return fmt.Sprintf("Error listing worktrees: %v", err) - } - - if len(items) == 0 { - return "No active worktrees in workspace/.worktrees." - } - - var sb strings.Builder - - sb.WriteString("Active worktrees\n\n") - - for _, wt := range items { - status := "clean" - - if wt.HasUncommitted { - status = "dirty" - } - - last := "(no commits)" - - if wt.LastCommitHash != "" { - if wt.LastCommitAge != "" { - last = fmt.Sprintf("%s %s (%s)", wt.LastCommitHash, wt.LastCommitSubject, wt.LastCommitAge) - } else { - last = fmt.Sprintf("%s %s", wt.LastCommitHash, wt.LastCommitSubject) - } - } - - fmt.Fprintf(&sb, "- %s\n branch: %s\n status: %s\n last: %s\n", wt.Name, wt.Branch, status, last) - } - - sb.WriteString("\nCommands:\n") - - sb.WriteString("/plan worktrees inspect <name>\n") - - sb.WriteString("/plan worktrees merge <name>\n") - - sb.WriteString("/plan worktrees dispose <name> [force]") - - return sb.String() - - case "inspect": - - if len(args) < 2 { - return "Usage: /plan worktrees inspect <name>" - } - - name := args[1] - - wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name) - if err != nil { - if errors.Is(err, git.ErrInvalidWorktreeName) { - return "Invalid worktree name." - } - - if errors.Is(err, git.ErrWorktreeNotFound) { - return fmt.Sprintf("Worktree %q not found.", name) - } - - return fmt.Sprintf("Error inspecting worktree %q: %v", name, err) - } - - statusOut, _ := git.WorktreeStatusShort(wt.Path) - - diffOut, _ := git.WorktreeDiffStat(wt.Path) - - logOut, _ := git.WorktreeRecentLog(wt.Path, 10) - - if statusOut == "" { - statusOut = "(clean)" - } - - var sb strings.Builder - - fmt.Fprintf(&sb, "Worktree: %s\nBranch: %s\nDirty: %t\n", wt.Name, wt.Branch, wt.HasUncommitted) - - if wt.LastCommitHash != "" { - fmt.Fprintf(&sb, "Last commit: %s %s", wt.LastCommitHash, wt.LastCommitSubject) - - if wt.LastCommitAge != "" { - fmt.Fprintf(&sb, " (%s)", wt.LastCommitAge) - } - - sb.WriteString("\n") - } - - sb.WriteString("\nStatus:\n```\n") - - sb.WriteString(statusOut) - - sb.WriteString("\n```\n") - - if diffOut != "" { - sb.WriteString("\nDiff (stat):\n```\n") - - sb.WriteString(diffOut) - - sb.WriteString("\n```\n") - } - - if logOut != "" { - sb.WriteString("\nRecent commits:\n```\n") - - sb.WriteString(logOut) - - sb.WriteString("\n```") - } - - return sb.String() - - case "merge": - - if len(args) < 2 { - return "Usage: /plan worktrees merge <name>" - } - - name := args[1] - - res, base, err := git.MergeManagedWorktree(repoRoot, worktreesDir, name, "") - if err != nil { - if errors.Is(err, git.ErrInvalidWorktreeName) { - return "Invalid worktree name." - } - - if errors.Is(err, git.ErrWorktreeNotFound) { - return fmt.Sprintf("Worktree %q not found.", name) - } - - return fmt.Sprintf("Error merging worktree %q: %v", name, err) - } - - if res.Conflict { - return fmt.Sprintf("Merge conflict while merging `%s` into `%s`. Merge was aborted.", res.Branch, base) - } - - if res.Merged { - return fmt.Sprintf("Merged `%s` into `%s`.", res.Branch, base) - } - - return fmt.Sprintf("No merge was performed for `%s`.", name) - - case "dispose": - - if len(args) < 2 { - return "Usage: /plan worktrees dispose <name> [force]" - } - - name := args[1] - - force := len(args) > 2 && strings.EqualFold(args[2], "force") - - wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name) - if err != nil { - if errors.Is(err, git.ErrInvalidWorktreeName) { - return "Invalid worktree name." - } - - if errors.Is(err, git.ErrWorktreeNotFound) { - return fmt.Sprintf("Worktree %q not found.", name) - } - - return fmt.Sprintf("Error disposing worktree %q: %v", name, err) - } - - if wt.HasUncommitted && !force { - return fmt.Sprintf( - - "Worktree `%s` has uncommitted changes. Re-run with `/plan worktrees dispose %s force` to confirm.", - - name, - - name, - ) - } - - res, err := git.DisposeManagedWorktree(repoRoot, worktreesDir, name, "") - if err != nil { - return fmt.Sprintf("Error disposing worktree %q: %v", name, err) - } - - parts := []string{fmt.Sprintf("Disposed worktree `%s` (branch `%s`).", name, res.Branch)} - - if res.AutoCommitted { - parts = append(parts, "Uncommitted changes were auto-committed.") - } - - if res.CommitsAhead > 0 { - parts = append(parts, fmt.Sprintf("Branch has %d unique commit(s); branch was kept.", res.CommitsAhead)) - } - - if res.BranchDeleted { - parts = append(parts, "Branch was deleted (no unique commits).") - } - - return strings.Join(parts, " ") - } - - return "Usage: /plan worktrees [list|inspect <name>|merge <name>|dispose <name> [force]]" -} - -func isPlanPreExecution(status string) bool { - return status == "interviewing" || status == "review" -} - -// interviewAllowedTools is the single source of truth for tool names that may - -// be sent to the LLM (and subsequently invoked) during the interview phase. - -// filterInterviewTools uses this to strip tool *definitions* before the LLM call, - -// while isToolAllowedDuringInterview adds argument-level checks as a second gate. - -var interviewAllowedTools = map[string]bool{ - "readfile": true, - - "listdir": true, - - "websearch": true, - - "webfetch": true, - - "message": true, - - "editfile": true, - - "appendfile": true, - - "writefile": true, - - "exec": true, - - "logs": true, -} - -// filterInterviewTools removes tool definitions that are not in the - -// interviewAllowedTools whitelist, reducing token usage and preventing the - -// LLM from attempting disallowed tool calls during the interview phase. - -func filterInterviewTools(defs []providers.ToolDefinition) []providers.ToolDefinition { - filtered := make([]providers.ToolDefinition, 0, len(defs)) - - for _, d := range defs { - if interviewAllowedTools[tools.NormalizeToolName(d.Function.Name)] { - filtered = append(filtered, d) - } - } - - return filtered -} - -// isToolAllowedDuringInterview checks whether a tool call is permitted while the - -// plan is in a pre-execution state. Uses the shared interviewAllowedTools map for - -// name-level gating, then applies argument-level constraints for write-type tools - -// (MEMORY.md only) and exec (read-only commands only). - -func isToolAllowedDuringInterview(toolName string, args map[string]any) bool { - norm := tools.NormalizeToolName(toolName) - - if !interviewAllowedTools[norm] { - return false - } - - // Argument-level constraints - - switch norm { - case "editfile", "appendfile", "writefile": - - path, _ := args["path"].(string) - - return strings.HasSuffix(path, "MEMORY.md") - - case "exec": - - cmd, _ := args["command"].(string) - - return isReadOnlyCommand(cmd) - } - - return true -} - -// isReadOnlyCommand returns true when cmd is a safe, read-only shell command - -// that an LLM may run during the interview phase. - -func isReadOnlyCommand(cmd string) bool { - cmd = strings.TrimSpace(cmd) - - if cmd == "" { - return false - } - - // Reject write operators anywhere in the command - - for _, op := range []string{">", ">>", "| tee "} { - if strings.Contains(cmd, op) { - return false - } - } - - // Reject path traversal (defense in depth; ExecTool.guardCommand also enforces workspace restriction) - - if strings.Contains(cmd, "..") { - return false - } - - // Block absolute paths in arguments (allow "cd /path && cmd" which is stripped later) - - for _, field := range strings.Fields(cmd) { - if strings.HasPrefix(field, "/") && !strings.HasPrefix(cmd, "cd ") { - return false - } - } - - // Strip "cd /path &&" prefix (LLM habit) - - if strings.HasPrefix(cmd, "cd ") { - if idx := strings.Index(cmd, "&&"); idx >= 0 { - cmd = strings.TrimSpace(cmd[idx+2:]) - } - } - - fields := strings.Fields(cmd) - - if len(fields) == 0 { - return false - } - - first := filepath.Base(fields[0]) - - switch first { - case "find", "ls", "cat", "head", "tail", "grep", "rg", - - "tree", "wc", "file", "which", "pwd", - - "uname", "df", "du", "stat", "realpath", "dirname", - - "basename", "date": - - return true - } - - return false -} - -// isWriteTool returns true if the tool can modify files. - -func isWriteTool(name string) bool { - switch tools.NormalizeToolName(name) { - case "writefile", "editfile", "appendfile", "exec": - - return true - } - - return false -} - -// expandPlanCommand detects "/plan <task>" (new plan start) and: - -// - writes the interview seed to MEMORY.md - -// - rewrites the message content for the LLM - -// - returns a compact form for session history - -// - -// This follows the same pattern as expandSkillCommand: the message is - -// rewritten before reaching the LLM, so the AI sees the task description - -// while the system prompt contains the interview guide. - -func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) { - content := strings.TrimSpace(msg.Content) - - if !strings.HasPrefix(content, "/plan ") { - return "", "", false - } - - task := strings.TrimSpace(content[6:]) // len("/plan ") == 6 - - if task == "" { - return "", "", false - } - - // Known subcommands are handled by handlePlanCommand (fast path). - - firstWord := strings.Fields(task)[0] - - switch firstWord { - case "clear", "done", "add", "start", "next", "worktrees": - - return "", "", false - } - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - return "", "", false - } - - // If a plan is already active, don't expand — handleCommand will - - // catch it and return the error on the fast path. - - if agent.ContextBuilder.HasActivePlan() { - return "", "", false - } - - // Write the interview seed - - seed := BuildInterviewSeed(task, agent.Workspace) - - if err := agent.ContextBuilder.WriteMemory(seed); err != nil { - return "", "", false - } - - al.notifyStateChange() - - // Expanded: the task description goes to LLM. - - // The system prompt already contains the interview guide. - - expanded = task - - compact = fmt.Sprintf("[Plan: %s]", utils.Truncate(task, 80)) - - return expanded, compact, true -} - -// extractPeer extracts the routing peer from the inbound message's structured Peer field. - -func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { - if msg.Peer.Kind == "" { - return nil - } - - peerID := msg.Peer.ID - - if peerID == "" { - if msg.Peer.Kind == "direct" { - peerID = msg.SenderID - } else { - peerID = msg.ChatID - } - } - - return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID} -} - -// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. - -func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { - parentKind := msg.Metadata["parent_peer_kind"] - - parentID := msg.Metadata["parent_peer_id"] - - if parentKind == "" || parentID == "" { - return nil - } - - return &routing.RoutePeer{Kind: parentKind, ID: parentID} -} diff --git a/pkg/agent/loop_commands.go b/pkg/agent/loop_commands.go new file mode 100644 index 000000000..0b51f8b0b --- /dev/null +++ b/pkg/agent/loop_commands.go @@ -0,0 +1,717 @@ +package agent + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/stats" +) + +func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { + content := strings.TrimSpace(msg.Content) + + if !strings.HasPrefix(content, "/") { + return "", false + } + + parts := strings.Fields(content) + + if len(parts) == 0 { + return "", false + } + + cmd := parts[0] + + args := parts[1:] + + switch cmd { + case "/show": + + if len(args) < 1 { + return "Usage: /show [model|channel|agents]", true + } + + switch args[0] { + case "model": + + defaultAgent := al.registry.GetDefaultAgent() + + if defaultAgent == nil { + return "No default agent configured", true + } + + return fmt.Sprintf("Current model: %s", defaultAgent.Model), true + + case "channel": + + return fmt.Sprintf("Current channel: %s", msg.Channel), true + + case "agents": + + agentIDs := al.registry.ListAgentIDs() + + return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true + + default: + + return fmt.Sprintf("Unknown show target: %s", args[0]), true + } + + case "/list": + + if len(args) < 1 { + return "Usage: /list [models|channels|agents]", true + } + + switch args[0] { + case "models": + + return "Available models: configured in config.json per agent", true + + case "channels": + + if al.channelManager == nil { + return "Channel manager not initialized", true + } + + channels := al.channelManager.GetEnabledChannels() + + if len(channels) == 0 { + return "No channels enabled", true + } + + return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true + + case "agents": + + agentIDs := al.registry.ListAgentIDs() + + return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true + + default: + + return fmt.Sprintf("Unknown list target: %s", args[0]), true + } + + case "/switch": + + if len(args) < 3 || args[1] != "to" { + return "Usage: /switch [model|channel] to <name>", true + } + + target := args[0] + + value := args[2] + + switch target { + case "model": + + defaultAgent := al.registry.GetDefaultAgent() + + if defaultAgent == nil { + return "No default agent configured", true + } + + oldModel := defaultAgent.Model + + defaultAgent.Model = value + + return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true + + case "channel": + + if al.channelManager == nil { + return "Channel manager not initialized", true + } + + if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { + return fmt.Sprintf("Channel '%s' not found or not enabled", value), true + } + + return fmt.Sprintf("Switched target channel to %s", value), true + + default: + + return fmt.Sprintf("Unknown switch target: %s", target), true + } + + case "/session": + + return al.handleSessionCommand(args, msg.SessionKey), true + + case "/skills": + + return al.handleSkillsCommand(), true + + case "/plan": + + resp, handled := al.handlePlanCommand(args, msg.SessionKey) + + if handled { + al.notifyStateChange() + } + + return resp, handled + + case "/heartbeat": + + resp, handled := al.handleHeartbeatCommand(args, msg) + + if handled { + al.notifyStateChange() + } + + return resp, handled + } + + return "", false +} + +func (al *AgentLoop) handleHeartbeatCommand(args []string, msg bus.InboundMessage) (string, bool) { + if len(args) == 0 { + return "Usage: /heartbeat thread [here|off|<thread_id>]", true + } + + if args[0] != "thread" { + return "Usage: /heartbeat thread [here|off|<thread_id>]", true + } + + if len(args) < 2 { + return "Usage: /heartbeat thread [here|off|<thread_id>]", true + } + + if msg.Channel != "telegram" { + return "/heartbeat thread is only supported from Telegram chats.", true + } + + baseChatID, currentThreadID := splitChatAndThread(msg.ChatID) + + if baseChatID == "" { + return "Unable to detect Telegram chat ID for heartbeat routing.", true + } + + arg := strings.ToLower(strings.TrimSpace(args[1])) + + var threadID int + + var err error + + switch arg { + case "off", "disable", "clear": + + threadID = 0 + + case "here", "this": + + if currentThreadID <= 0 { + return "Current Telegram message is not in a thread. Usage: /heartbeat thread <thread_id>", true + } + + threadID = currentThreadID + + default: + + threadID, err = strconv.Atoi(arg) + + if err != nil || threadID < 0 { + return "Usage: /heartbeat thread [here|off|<thread_id>]", true + } + } + + al.cfg.Channels.Telegram.HeartbeatThreadID = threadID + + if al.state != nil { + _ = al.state.SetHeartbeatTarget(fmt.Sprintf("telegram:%s", baseChatID)) + } + + if al.onHeartbeatThreadUpdate != nil { + al.onHeartbeatThreadUpdate(threadID) + } + + if al.saveConfig != nil { + if err := al.saveConfig(al.cfg); err != nil { + return fmt.Sprintf("Failed to persist config.json: %v", err), true + } + } + + if threadID == 0 { + return fmt.Sprintf("Heartbeat thread routing disabled for chat %s and saved to config.json.", baseChatID), true + } + + return fmt.Sprintf("Heartbeat thread set to %d for chat %s and saved to config.json.", threadID, baseChatID), true +} + +func splitChatAndThread(chatID string) (baseChatID string, threadID int) { + baseChatID = strings.TrimSpace(chatID) + + if baseChatID == "" { + return "", 0 + } + + if slash := strings.Index(baseChatID, "/"); slash >= 0 { + threadPart := strings.TrimSpace(baseChatID[slash+1:]) + + baseChatID = strings.TrimSpace(baseChatID[:slash]) + + if tid, err := strconv.Atoi(threadPart); err == nil && tid > 0 { + threadID = tid + } + } + + return baseChatID, threadID +} + +// handleSessionCommand dispatches /session subcommands. + +func (al *AgentLoop) handleSessionCommand(args []string, sessionKey string) string { + sub := "" + + if len(args) > 0 { + sub = strings.ToLower(strings.TrimSpace(args[0])) + } + + switch sub { + case "list": + + return al.handleSessionList() + + case "graph": + + return al.handleSessionGraph() + + case "fork": + + return al.handleSessionFork(args[1:], sessionKey) + + case "reset": + + if al.stats == nil { + return "Stats tracking is disabled." + } + + al.stats.Reset() + + return "Session statistics have been reset." + + default: + + return al.handleSessionStats() + } +} + +func (al *AgentLoop) handleSessionStats() string { + agent := al.registry.GetDefaultAgent() + + store := agent.Sessions.Store() + + // Session DAG summary + + sessions, _ := store.List(nil) + + var sb strings.Builder + + fmt.Fprintf(&sb, "Sessions: %d in store\n", len(sessions)) + + if len(sessions) > 0 { + active, completed := 0, 0 + + for _, s := range sessions { + switch s.Status { + case "active": + + active++ + + case "completed": + + completed++ + } + } + + fmt.Fprintf(&sb, " active=%d completed=%d\n", active, completed) + } + + sb.WriteString("\nUse: /session list | graph | fork [label]\n") + + // Token stats if available + + if al.stats != nil { + s := al.stats.GetStats() + + fmt.Fprintf(&sb, + + "\nToken Stats — Today (%s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)\n"+ + + "All time (since %s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)", + + s.Today.Date, + + s.Today.Prompts, + + s.Today.Requests, + + stats.FormatTokenCount(s.Today.TotalTokens), + + stats.FormatTokenCount(s.Today.PromptTokens), + + stats.FormatTokenCount(s.Today.CompletionTokens), + + s.Since.Format("2006-01-02"), + + s.TotalPrompts, + + s.TotalRequests, + + stats.FormatTokenCount(s.TotalTokens), + + stats.FormatTokenCount(s.TotalPromptTokens), + + stats.FormatTokenCount(s.TotalCompletionTokens), + ) + } + + return sb.String() +} + +// shortSessionKey truncates long session keys for display. + +func shortSessionKey(key string) string { + parts := strings.Split(key, ":") + + if len(parts) > 2 { + return strings.Join(parts[2:], ":") + } + + return key +} + +func (al *AgentLoop) handleSessionList() string { + agent := al.registry.GetDefaultAgent() + + store := agent.Sessions.Store() + + sessions, err := store.List(nil) + if err != nil { + return fmt.Sprintf("Error listing sessions: %v", err) + } + + if len(sessions) == 0 { + return "No sessions in store." + } + + var sb strings.Builder + + fmt.Fprintf(&sb, "Sessions (%d)\n", len(sessions)) + + for _, s := range sessions { + age := time.Since(s.UpdatedAt).Truncate(time.Second) + + label := s.Label + + if label == "" { + label = shortSessionKey(s.Key) + } + + parent := "" + + if s.ParentKey != "" { + parent = " parent=" + shortSessionKey(s.ParentKey) + } + + fmt.Fprintf(&sb, "- %s [%s] (%s) turns=%d%s\n", + + label, s.Status, age, s.TurnCount, parent) + } + + return sb.String() +} + +func (al *AgentLoop) handleSessionGraph() string { + agent := al.registry.GetDefaultAgent() + + store := agent.Sessions.Store() + + sessions, err := store.List(nil) + if err != nil { + return fmt.Sprintf("Error listing sessions: %v", err) + } + + if len(sessions) == 0 { + return "No sessions in store." + } + + // Build parent→children map and find roots + + byKey := make(map[string]*session.SessionInfo, len(sessions)) + + children := make(map[string][]string) + + var roots []string + + for _, s := range sessions { + byKey[s.Key] = s + + if s.ParentKey == "" { + roots = append(roots, s.Key) + } else { + children[s.ParentKey] = append(children[s.ParentKey], s.Key) + } + } + + var sb strings.Builder + + sb.WriteString("Session Graph\n") + + for i, root := range roots { + last := i == len(roots)-1 + + printSessionTree(&sb, root, byKey, children, "", last) + } + + return sb.String() +} + +func printSessionTree( + sb *strings.Builder, + key string, + byKey map[string]*session.SessionInfo, + children map[string][]string, + prefix string, + last bool, +) { + s := byKey[key] + + if s == nil { + return + } + + connector := "├── " + + if last { + connector = "└── " + } + + icon := "●" + + if s.Status == "completed" { + icon = "✓" + } + + label := s.Label + + if label == "" { + label = shortSessionKey(s.Key) + } + + fmt.Fprintf(sb, "%s%s%s %s (turns=%d)\n", prefix, connector, icon, label, s.TurnCount) + + childPrefix := prefix + "│ " + + if last { + childPrefix = prefix + " " + } + + kids := children[key] + + for i, childKey := range kids { + printSessionTree(sb, childKey, byKey, children, childPrefix, i == len(kids)-1) + } +} + +func (al *AgentLoop) handleSessionFork(args []string, sessionKey string) string { + if sessionKey == "" { + return "Cannot fork: no active session key." + } + + agent := al.registry.GetDefaultAgent() + + store := agent.Sessions.Store() + + label := "fork" + + if len(args) > 0 { + label = strings.Join(args, " ") + } + + childKey := sessionKey + ":fork:" + time.Now().Format("20060102T150405") + + err := store.Fork(sessionKey, childKey, &session.CreateOpts{Label: label}) + if err != nil { + return fmt.Sprintf("Fork failed: %v", err) + } + + return fmt.Sprintf( + "Forked session\n parent: %s\n child: %s", + shortSessionKey(sessionKey), + shortSessionKey(childKey), + ) +} + +type SessionGraphNode struct { + Key string `json:"key"` + + Label string `json:"label"` + + Status string `json:"status"` + + Summary string `json:"summary"` + + ParentKey string `json:"parent_key"` + + ForkTurnID string `json:"fork_turn_id"` + + TurnCount int `json:"turn_count"` + + CreatedAt time.Time `json:"created_at"` + + UpdatedAt time.Time `json:"updated_at"` +} + +// GetSessionGraph returns all sessions as a flat list of graph nodes. + +func (al *AgentLoop) GetSessionGraph() []SessionGraphNode { + agent := al.registry.GetDefaultAgent() + + store := agent.Sessions.Store() + + sessions, err := store.List(nil) + if err != nil { + return nil + } + + nodes := make([]SessionGraphNode, 0, len(sessions)) + + for _, s := range sessions { + nodes = append(nodes, SessionGraphNode{ + Key: s.Key, + + Label: s.Label, + + Status: s.Status, + + Summary: s.Summary, + + ParentKey: s.ParentKey, + + ForkTurnID: s.ForkTurnID, + + TurnCount: s.TurnCount, + + CreatedAt: s.CreatedAt, + + UpdatedAt: s.UpdatedAt, + }) + } + + return nodes +} + +// expandSkillCommand detects "/skill <name> [message]" and returns: + +// - expanded: full content with SKILL.md injected (for LLM) + +// - compact: skill name tag + user message only (for history) + +// - ok: whether expansion happened + +func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) { + content := strings.TrimSpace(msg.Content) + + if !strings.HasPrefix(content, "/skill ") { + return "", "", false + } + + // Parse: /skill <name> [message] + + rest := strings.TrimSpace(content[7:]) // len("/skill ") == 7 + + parts := strings.SplitN(rest, " ", 2) + + if len(parts) == 0 || parts[0] == "" { + return "", "", false + } + + skillName := parts[0] + + userMessage := "" + + if len(parts) > 1 { + userMessage = strings.TrimSpace(parts[1]) + } + + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return "", "", false + } + + skillContent, found := agent.ContextBuilder.LoadSkill(skillName) + + if !found { + return "", "", false + } + + tag := fmt.Sprintf("[Skill: %s]", skillName) + + // Build expanded message: skill instructions + user message (for LLM) + + var sb strings.Builder + + sb.WriteString(tag) + + sb.WriteString("\n\n") + + sb.WriteString(skillContent) + + if userMessage != "" { + sb.WriteString("\n\n---\n\n") + + sb.WriteString(userMessage) + } + + // Build compact form: skill name tag + user message only (for history) + + compactForm := tag + + if userMessage != "" { + compactForm = tag + "\n" + userMessage + } + + return sb.String(), compactForm, true +} + +// handleSkillsCommand lists all available skills. + +func (al *AgentLoop) handleSkillsCommand() string { + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return "No agent configured." + } + + skillsList := agent.ContextBuilder.ListSkills() + + if len(skillsList) == 0 { + return "No skills available.\nAdd skills to your workspace/skills/ directory." + } + + var sb strings.Builder + + sb.WriteString("Available Skills\n\n") + + for _, s := range skillsList { + fmt.Fprintf(&sb, "**%s** (%s)\n", s.Name, s.Source) + + if s.Description != "" { + fmt.Fprintf(&sb, "```\n%s\n```\n", s.Description) + } + } + + sb.WriteString("\nUse: /skill <name> [message]") + + return sb.String() +} diff --git a/pkg/agent/loop_ext_test.go b/pkg/agent/loop_ext_test.go new file mode 100644 index 000000000..79e12b80f --- /dev/null +++ b/pkg/agent/loop_ext_test.go @@ -0,0 +1,2883 @@ +package agent + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" + "github.com/sipeed/picoclaw/pkg/tools" +) + +func TestRecordLastHeartbeatTarget(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "test-model", + + MaxTokens: 4096, + + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + + provider := &mockProvider{} + + al := NewAgentLoop(cfg, msgBus, provider) + + target := "telegram:-100123/42" + + if err := al.RecordLastHeartbeatTarget(target); err != nil { + t.Fatalf("RecordLastHeartbeatTarget failed: %v", err) + } + + if got := al.state.GetLastHeartbeatTarget(); got != target { + t.Fatalf("GetLastHeartbeatTarget = %q, want %q", got, target) + } +} + +func newTestAgentLoopSimple(t *testing.T) (*AgentLoop, func()) { + t.Helper() + al, _, _, _, cleanup := newTestAgentLoop(t) //nolint:dogsled + return al, cleanup +} + +func TestShouldInjectReminder(t *testing.T) { + tests := []struct { + name string + + iteration int + + interval int + + want bool + }{ + {"first iteration skipped", 1, 5, false}, + + {"iteration 5 interval 5", 5, 5, true}, + + {"iteration 10 interval 5", 10, 5, true}, + + {"iteration 3 interval 5", 3, 5, false}, + + {"interval zero disabled", 5, 0, false}, + + {"interval negative disabled", 5, -1, false}, + + {"iteration 2 interval 1", 2, 1, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := shouldInjectReminder(tt.iteration, tt.interval) + + if got != tt.want { + t.Errorf("shouldInjectReminder(%d, %d) = %v, want %v", tt.iteration, tt.interval, got, tt.want) + } + }) + } +} + +func TestBuildTaskReminder_WithoutBlocker(t *testing.T) { + msg := buildTaskReminder("implement feature X", "") + + if msg.Role != "user" { + t.Errorf("expected role 'user', got %q", msg.Role) + } + + if !strings.Contains(msg.Content, "[TASK REMINDER]") { + t.Error("expected content to contain '[TASK REMINDER]'") + } + + if !strings.Contains(msg.Content, "implement feature X") { + t.Error("expected content to contain original message") + } + + if strings.Contains(msg.Content, "blocker") { + t.Error("expected content NOT to contain 'blocker' when no blocker provided") + } + + if !strings.Contains(msg.Content, "move on") { + t.Error("expected content to contain completion prompt") + } +} + +func TestBuildTaskReminder_WithBlocker(t *testing.T) { + msg := buildTaskReminder("implement feature X", "ModuleNotFoundError: No module named 'foo'") + + if msg.Role != "user" { + t.Errorf("expected role 'user', got %q", msg.Role) + } + + if !strings.Contains(msg.Content, "[TASK REMINDER]") { + t.Error("expected content to contain '[TASK REMINDER]'") + } + + if !strings.Contains(msg.Content, "implement feature X") { + t.Error("expected content to contain original message") + } + + if !strings.Contains(msg.Content, "Last blocker") { + t.Error("expected content to contain 'Last blocker'") + } + + if !strings.Contains(msg.Content, "ModuleNotFoundError") { + t.Error("expected content to contain blocker text") + } +} + +func TestResolveProvider_CachesProviders(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "test-model", + + Provider: "vllm", + + MaxTokens: 4096, + + MaxToolIterations: 10, + }, + }, + + Providers: config.ProvidersConfig{ + VLLM: config.ProviderConfig{ + APIKey: "test-key", + + APIBase: "https://example.com/v1", + }, + }, + } + + msgBus := bus.NewMessageBus() + + primary := &mockProvider{} + + al := NewAgentLoop(cfg, msgBus, primary) + + p1 := al.resolveProvider("vllm", "test-model", primary) + + if p1 == primary { + t.Fatal("expected a new provider from legacy providers config, not the fallback") + } + + p2 := al.resolveProvider("vllm", "test-model", primary) + + if p1 != p2 { + t.Fatal("expected same cached instance on second call") + } +} + +func TestResolveProvider_FallsBackOnError(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "test-model", + + Provider: "vllm", + + MaxTokens: 4096, + + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + + primary := &mockProvider{} + + al := NewAgentLoop(cfg, msgBus, primary) + + p := al.resolveProvider("nonexistent", "unknown-model", primary) + + if p != primary { + t.Fatal("expected fallback to primary provider on creation error") + } + + if _, ok := al.providerCache["nonexistent"]; ok { + t.Fatal("failed provider should not be cached") + } +} + +func TestResolveProvider_EmptyNameReturnsFallback(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "test-model", + + MaxTokens: 4096, + + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + + primary := &mockProvider{} + + al := NewAgentLoop(cfg, msgBus, primary) + + p := al.resolveProvider("", "", primary) + + if p != primary { + t.Fatal("expected fallback provider for empty name") + } +} + +func TestSlashCommandResponseSkipsPlaceholder(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "test-model", + + MaxTokens: 4096, + + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + + provider := &mockProvider{} + + al := NewAgentLoop(cfg, msgBus, provider) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + + defer cancel() + + go func() { + _ = al.Run(ctx) + }() + + msgBus.PublishInbound(context.Background(), bus.InboundMessage{ + Channel: "telegram", + + SenderID: "user1", + + ChatID: "chat1", + + Content: "/skills", + }) + + outMsg, ok := msgBus.SubscribeOutbound(ctx) + + if !ok { + t.Fatal("expected outbound message from slash command") + } + + if !outMsg.SkipPlaceholder { + t.Errorf("expected SkipPlaceholder=true for slash command response, got false") + } +} + +func TestBuildTaskReminder_Truncation(t *testing.T) { + longMsg := strings.Repeat("あ", 1000) + + longBlocker := strings.Repeat("X", 500) + + msg := buildTaskReminder(longMsg, longBlocker) + + runeCount := strings.Count(msg.Content, "あ") + + if runeCount >= 1000 { + t.Errorf("expected task message to be truncated, got %d 'あ' runes", runeCount) + } + + if runeCount > taskReminderMaxChars { + t.Errorf("expected at most %d task runes, got %d", taskReminderMaxChars, runeCount) + } + + xCount := strings.Count(msg.Content, "X") + + if xCount >= 500 { + t.Errorf("expected blocker to be truncated, got %d 'X' chars", xCount) + } + + if xCount > blockerMaxChars { + t.Errorf("expected at most %d blocker chars, got %d", blockerMaxChars, xCount) + } +} + +func TestBuildPlanReminder(t *testing.T) { + tests := []struct { + name string + + status string + + wantOK bool + + wantSubstr string + }{ + {"interviewing", "interviewing", true, "interviewing the user"}, + + {"review", "review", true, "under review"}, + + {"executing returns false", "executing", false, ""}, + + {"empty returns false", "", false, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msg, ok := buildPlanReminder(tt.status) + + if ok != tt.wantOK { + t.Fatalf("buildPlanReminder(%q) ok = %v, want %v", tt.status, ok, tt.wantOK) + } + + if !ok { + return + } + + if msg.Role != "user" { + t.Errorf("expected role 'user', got %q", msg.Role) + } + + if !strings.Contains(msg.Content, tt.wantSubstr) { + t.Errorf("expected content to contain %q, got %q", tt.wantSubstr, msg.Content) + } + }) + } +} + +func TestPlanCommand_ShowNoPlan(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"}) + + if !handled { + t.Fatal("expected /plan to be handled") + } + + if !strings.Contains(response, "No active plan") { + t.Errorf("expected 'No active plan', got %q", response) + } +} + +func TestSplitChatAndThread(t *testing.T) { + tests := []struct { + name string + + chatID string + + wantChatID string + + wantThread int + }{ + {name: "plain chat", chatID: "-100123", wantChatID: "-100123", wantThread: 0}, + + {name: "chat with thread", chatID: "-100123/77", wantChatID: "-100123", wantThread: 77}, + + {name: "invalid thread", chatID: "-100123/abc", wantChatID: "-100123", wantThread: 0}, + + {name: "empty", chatID: "", wantChatID: "", wantThread: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotChatID, gotThread := splitChatAndThread(tt.chatID) + + if gotChatID != tt.wantChatID || gotThread != tt.wantThread { + t.Fatalf( + + "splitChatAndThread(%q) = (%q, %d), want (%q, %d)", + + tt.chatID, + + gotChatID, + + gotThread, + + tt.wantChatID, + + tt.wantThread, + ) + } + }) + } +} + +func TestHeartbeatCommandThreadHerePersistsConfig(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + var saved bool + + var updatedThread int + + al.SetConfigSaver(func(cfg *config.Config) error { + saved = true + + if cfg.Channels.Telegram.HeartbeatThreadID != 42 { + t.Fatalf("HeartbeatThreadID in saver = %d, want 42", cfg.Channels.Telegram.HeartbeatThreadID) + } + + return nil + }) + + al.SetHeartbeatThreadUpdater(func(threadID int) { updatedThread = threadID }) + + msg := bus.InboundMessage{ + Content: "/heartbeat thread here", + + Channel: "telegram", + + ChatID: "-100500/42", + } + + resp, handled := al.handleCommand(context.Background(), msg) + + if !handled { + t.Fatal("expected /heartbeat command to be handled") + } + + if !strings.Contains(resp, "Heartbeat thread set to 42") { + t.Fatalf("unexpected response: %q", resp) + } + + if !saved { + t.Fatal("expected config saver to be called") + } + + if updatedThread != 42 { + t.Fatalf("updatedThread = %d, want 42", updatedThread) + } + + if got := al.cfg.Channels.Telegram.HeartbeatThreadID; got != 42 { + t.Fatalf("cfg heartbeat thread = %d, want 42", got) + } + + if got := al.state.GetHeartbeatTarget(); got != "telegram:-100500" { + t.Fatalf("state heartbeat target = %q, want %q", got, "telegram:-100500") + } +} + +func TestHeartbeatCommandThreadOff(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + al.cfg.Channels.Telegram.HeartbeatThreadID = 99 + + resp, handled := al.handleCommand(context.Background(), bus.InboundMessage{ + Content: "/heartbeat thread off", + + Channel: "telegram", + + ChatID: "-100500/42", + }) + + if !handled { + t.Fatal("expected /heartbeat command to be handled") + } + + if !strings.Contains(resp, "disabled") { + t.Fatalf("unexpected response: %q", resp) + } + + if got := al.cfg.Channels.Telegram.HeartbeatThreadID; got != 0 { + t.Fatalf("cfg heartbeat thread = %d, want 0", got) + } +} + +func TestPlanCommand_StartNewPlan(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + _, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Set up monitoring"}) + + if handled { + t.Fatal("expected /plan <task> NOT to be handled (should fall through to LLM)") + } + + msg := bus.InboundMessage{Content: "/plan Set up monitoring"} + + expanded, compact, ok := al.expandPlanCommand(msg) + + if !ok { + t.Fatal("expected expandPlanCommand to succeed") + } + + if expanded != "Set up monitoring" { + t.Errorf("expected expanded = 'Set up monitoring', got %q", expanded) + } + + if !strings.Contains(compact, "Set up monitoring") { + t.Errorf("expected compact to contain task, got %q", compact) + } + + agent := al.registry.GetDefaultAgent() + + if !agent.ContextBuilder.HasActivePlan() { + t.Error("expected active plan after expandPlanCommand") + } + + if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" { + t.Errorf("expected 'interviewing', got %q", status) + } +} + +func TestPlanCommand_StartBlockedByExisting(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + al.expandPlanCommand(bus.InboundMessage{Content: "/plan First task"}) + + response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Second task"}) + + if !handled { + t.Fatal("expected second /plan to be handled (blocked)") + } + + if !strings.Contains(response, "already active") { + t.Errorf("expected 'already active', got %q", response) + } +} + +func TestPlanCommand_Clear(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"}) + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"}) + + if !strings.Contains(response, "Plan cleared") { + t.Errorf("expected 'Plan cleared', got %q", response) + } + + agent := al.registry.GetDefaultAgent() + + if agent.ContextBuilder.HasActivePlan() { + t.Error("expected no plan after clear") + } +} + +func TestPlanCommand_ClearNoPlan(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"}) + + if !strings.Contains(response, "No active plan") { + t.Errorf("expected 'No active plan', got %q", response) + } +} + +func TestPlanCommand_Start(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + agent := al.registry.GetDefaultAgent() + + plan := "# Active Plan\n\n> Task: Test task\n> Status: interviewing\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" + + _ = agent.ContextBuilder.WriteMemory(plan) + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) + + if !strings.Contains(response, "approved") { + t.Errorf("expected 'approved', got %q", response) + } + + if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" { + t.Errorf("expected 'executing', got %q", status) + } + + if !al.planStartPending { + t.Error("expected planStartPending to be true after /plan start") + } +} + +func TestPlanCommand_StartFromReview(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + agent := al.registry.GetDefaultAgent() + + plan := "# Active Plan\n\n> Task: Test task\n> Status: review\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" + + _ = agent.ContextBuilder.WriteMemory(plan) + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) + + if !strings.Contains(response, "approved") { + t.Errorf("expected 'approved', got %q", response) + } + + if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" { + t.Errorf("expected 'executing', got %q", status) + } + + if !al.planStartPending { + t.Error("expected planStartPending to be true after /plan start from review") + } +} + +func TestPlanCommand_StartNoPhases(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"}) + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) + + if !strings.Contains(response, "no phases") { + t.Errorf("expected 'no phases' error, got %q", response) + } + + agent := al.registry.GetDefaultAgent() + + if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" { + t.Errorf("expected status to remain 'interviewing', got %q", status) + } + + if al.planStartPending { + t.Error("planStartPending must not be set when start is rejected (no phases)") + } +} + +func TestPlanCommand_StartAlreadyExecuting(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + agent := al.registry.GetDefaultAgent() + + plan := "# Active Plan\n\n> Task: Test task\n> Status: interviewing\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" + + _ = agent.ContextBuilder.WriteMemory(plan) + + al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) + + al.planStartPending = false + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) + + if !strings.Contains(response, "already executing") { + t.Errorf("expected 'already executing', got %q", response) + } + + if al.planStartPending { + t.Error("planStartPending must not be set when plan is already executing") + } +} + +func TestPlanCommand_Done(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + agent := al.registry.GetDefaultAgent() + + plan := `# Active Plan + + + +> Task: Test task + +> Status: executing + +> Phase: 1 + + + +## Phase 1: Setup + +- [ ] Step one + +- [ ] Step two + + + +## Context + +Test context + +` + + agent.ContextBuilder.WriteMemory(plan) + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done 1"}) + + if !strings.Contains(response, "Marked step 1") { + t.Errorf("expected confirmation, got %q", response) + } +} + +func TestPlanCommand_DoneInvalidStep(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"}) + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done abc"}) + + if !strings.Contains(response, "positive integer") { + t.Errorf("expected step validation error, got %q", response) + } +} + +func TestPlanCommand_Add(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + agent := al.registry.GetDefaultAgent() + + plan := `# Active Plan + + + +> Task: Test task + +> Status: executing + +> Phase: 1 + + + +## Phase 1: Setup + +- [ ] Step one + + + +## Context + +Test context + +` + + agent.ContextBuilder.WriteMemory(plan) + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan add New step here"}) + + if !strings.Contains(response, "Added step") { + t.Errorf("expected 'Added step', got %q", response) + } + + content := agent.ContextBuilder.ReadMemory() + + if !strings.Contains(content, "New step here") { + t.Error("expected new step in plan content") + } +} + +func TestPlanCommand_Next(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + agent := al.registry.GetDefaultAgent() + + plan := `# Active Plan + + + +> Task: Test task + +> Status: executing + +> Phase: 1 + + + +## Phase 1: Setup + +- [x] Step one + + + +## Phase 2: Deploy + +- [ ] Step two + + + +## Context + +Test + +` + + agent.ContextBuilder.WriteMemory(plan) + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan next"}) + + if !strings.Contains(response, "phase 2") { + t.Errorf("expected 'phase 2', got %q", response) + } + + if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 2 { + t.Errorf("expected phase 2, got %d", phase) + } +} + +func TestPlanCommand_ShowActivePlan(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + agent := al.registry.GetDefaultAgent() + + plan := `# Active Plan + + + +> Task: Deploy app + +> Status: executing + +> Phase: 1 + + + +## Phase 1: Build + +- [x] Compile code + +- [ ] Run tests + + + +## Context + +Production server + +` + + agent.ContextBuilder.WriteMemory(plan) + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"}) + + if !strings.Contains(response, "Deploy app") { + t.Errorf("expected task name in display, got %q", response) + } + + if !strings.Contains(response, "Phase 1") { + t.Errorf("expected phase info in display, got %q", response) + } +} + +func TestAutoPhaseAdvance(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-auto-advance-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "test-model", + + MaxTokens: 4096, + + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + + provider := &simpleMockProvider{response: "OK"} + + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.registry.GetDefaultAgent() + + if agent == nil { + t.Fatal("No default agent") + } + + plan := `# Active Plan + + + +> Task: Test auto advance + +> Status: executing + +> Phase: 1 + + + +## Phase 1: Setup + +- [x] Step one + +- [x] Step two + + + +## Phase 2: Deploy + +- [ ] Step three + + + +## Context + +Test + +` + + agent.ContextBuilder.WriteMemory(plan) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + defer cancel() + + _, err = al.ProcessDirectWithChannel(ctx, "continue", "auto-advance-test", "test", "chat1") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 2 { + t.Errorf("expected phase auto-advanced to 2, got %d", phase) + } +} + +func TestAutoCompleteClears(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-auto-complete-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "test-model", + + MaxTokens: 4096, + + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + + provider := &simpleMockProvider{response: "All done"} + + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.registry.GetDefaultAgent() + + if agent == nil { + t.Fatal("No default agent") + } + + plan := `# Active Plan + + + +> Task: Test auto complete + +> Status: executing + +> Phase: 1 + + + +## Phase 1: Setup + +- [x] Step one + +- [x] Step two + + + +## Context + +Test + +` + + agent.ContextBuilder.WriteMemory(plan) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + defer cancel() + + _, err = al.ProcessDirectWithChannel(ctx, "finish up", "auto-complete-test", "test", "chat1") + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + if !agent.ContextBuilder.HasActivePlan() { + t.Error("expected plan to be retained after completion") + } + + if status := agent.ContextBuilder.GetPlanStatus(); status != "completed" { + t.Errorf("expected plan status 'completed', got %q", status) + } + + if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 1 { + t.Errorf("expected phase 1 (total phases), got %d", phase) + } +} + +func TestIsToolAllowedDuringInterview_FuzzyNames(t *testing.T) { + tests := []struct { + name string + + args map[string]any + + want bool + }{ + {"read_file", nil, true}, + + {"list_dir", nil, true}, + + {"web_search", nil, true}, + + {"web_fetch", nil, true}, + + {"readfile", nil, true}, + + {"ReadFile", nil, true}, + + {"listdir", nil, true}, + + {"websearch", nil, true}, + + {"webfetch", nil, true}, + + {"message", nil, true}, + + {"Message", nil, true}, + + {"edit_file", map[string]any{"path": "/ws/memory/MEMORY.md"}, true}, + + {"editfile", map[string]any{"path": "/ws/memory/MEMORY.md"}, true}, + + {"EditFile", map[string]any{"path": "/ws/memory/MEMORY.md"}, true}, + + {"edit_file", map[string]any{"path": "/ws/main.go"}, false}, + + {"editfile", map[string]any{"path": "/ws/main.go"}, false}, + + {"exec", map[string]any{"command": "find . -name '*.py'"}, true}, + + {"exec", map[string]any{"command": "ls -la"}, true}, + + {"exec", map[string]any{"command": "grep -r TODO ."}, true}, + + {"exec", map[string]any{"command": "cat README.md"}, true}, + + {"exec", map[string]any{"command": "cd /home/user/project && find . -type f"}, true}, + + {"exec", map[string]any{"command": "cd /tmp && rm -rf *"}, false}, + + {"exec", map[string]any{"command": "find . > output.txt"}, false}, + + {"exec", map[string]any{"command": "ls -la >> log.txt"}, false}, + + {"exec", map[string]any{"command": "cat foo | tee bar.txt"}, false}, + + {"exec", map[string]any{"command": "cat ../../etc/passwd"}, false}, + + {"exec", map[string]any{"command": "find ../../"}, false}, + + {"exec", map[string]any{"command": "ls ../secret"}, false}, + + {"exec", map[string]any{"command": "cat /etc/passwd"}, false}, + + {"exec", map[string]any{"command": "find /etc -name '*.conf'"}, false}, + + {"exec", map[string]any{"command": "ls /root"}, false}, + + {"exec", map[string]any{"command": "rm -rf /"}, false}, + + {"exec", map[string]any{"command": "mv a b"}, false}, + + {"exec", nil, false}, + + {"exec", map[string]any{"command": ""}, false}, + + {"Exec", nil, false}, + } + + for _, tt := range tests { + got := isToolAllowedDuringInterview(tt.name, tt.args) + + if got != tt.want { + t.Errorf("isToolAllowedDuringInterview(%q, %v) = %v, want %v", tt.name, tt.args, got, tt.want) + } + } +} + +func TestBuildArgsSnippet_ExecStripsCD(t *testing.T) { + tests := []struct { + name string + + tool string + + args map[string]any + + workspace string + + wantSnip string + }{ + { + name: "exec strips cd prefix", + + tool: "exec", + + args: map[string]any{ + "command": "cd /home/user/workspace/project/my-projects && pytest tests/test_integration.py", + }, + + workspace: "/home/user/workspace", + + wantSnip: "pytest tests/test_integration.py", + }, + + { + name: "exec no cd prefix, flags stripped", + + tool: "exec", + + args: map[string]any{"command": "ls -la"}, + + workspace: "/ws", + + wantSnip: "ls", + }, + + { + name: "exec empty command", + + tool: "exec", + + args: map[string]any{}, + + workspace: "/ws", + + wantSnip: "{}", + }, + + { + name: "read_file strips workspace", + + tool: "read_file", + + args: map[string]any{"path": "/home/user/workspace/src/main.go"}, + + workspace: "/home/user/workspace", + + wantSnip: "src/main.go", + }, + + { + name: "edit_file shows path", + + tool: "edit_file", + + args: map[string]any{"path": "/ws/config.json", "old_text": "old value here"}, + + workspace: "/ws", + + wantSnip: "config.json", + }, + + { + name: "file tool long path prioritizes filename", + + tool: "read_file", + + args: map[string]any{ + "path": "/ws/projects/terra-py-form/src/terra_py_form/hot/state/backend.py", + }, + + workspace: "/ws", + + wantSnip: "projects/terra-py-form/src/terra_py_form/hot/sta\u2026/backend.py", + }, + + { + name: "unknown tool shows raw JSON", + + tool: "web_search", + + args: map[string]any{"query": "hello"}, + + workspace: "/ws", + + wantSnip: `{"query":"hello"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildArgsSnippet(tt.tool, tt.args, tt.workspace) + + if got != tt.wantSnip { + t.Errorf("buildArgsSnippet(%q) = %q, want %q", tt.tool, got, tt.wantSnip) + } + }) + } +} + +func TestFormatCompactEntry(t *testing.T) { + tests := []struct { + name string + + entry toolLogEntry + + wantSub string // must be a substring + + wantMark string // result marker must appear + + noTime bool // if true, duration should NOT appear + }{ + { + name: "exec short entry", + + entry: toolLogEntry{Name: "[1] exec", ArgsSnip: "ls", Result: "✓ 1.0s"}, + + wantSub: "exec ls", + + wantMark: "✓ 1.0s", + }, + + { + name: "exec long entry truncated from end", + + entry: toolLogEntry{ + Name: "[2] exec", + + ArgsSnip: "pytest tests/integration/test_very_long_name.py", + + Result: "✗ 3.0s", + }, + + wantMark: "✗", + }, + + { + name: "file tool omits duration, shows filename", + + entry: toolLogEntry{ + Name: "[3] edit_file", + + ArgsSnip: "projects/terra/src/deep/nested/backend.py", + + Result: "✓ 0.0s", + }, + + wantSub: "backend.py", + + wantMark: "✓", + + noTime: true, + }, + + { + name: "file tool path truncates from start", + + entry: toolLogEntry{ + Name: "[4] read_file", + + ArgsSnip: "projects/terra-py-form/src/terra_py_form/hot/state/backend.py", + + Result: "✓ 0.1s", + }, + + wantSub: "backend.py", + + wantMark: "✓", + + noTime: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatCompactEntry(tt.entry) + + if tt.wantSub != "" && !strings.Contains(got, tt.wantSub) { + t.Errorf("expected to contain %q, got: %q", tt.wantSub, got) + } + + if !strings.Contains(got, tt.wantMark) { + t.Errorf("result marker %q missing from: %q", tt.wantMark, got) + } + + if tt.noTime && strings.Contains(got, "0s") { + t.Errorf("file tool should omit duration, got: %q", got) + } + + if runeLen := len([]rune(got)); runeLen > maxEntryLineWidth { + t.Errorf("entry too wide: %d runes (max %d): %q", runeLen, maxEntryLineWidth, got) + } + }) + } +} + +func TestBuildRichStatus(t *testing.T) { + task := &activeTask{ + Iteration: 3, + + MaxIter: 20, + + toolLog: []toolLogEntry{ + {Name: "exec", ArgsSnip: "ls -la", Result: "✓ 1.2s"}, + + {Name: "exec", ArgsSnip: "pytest tests/", Result: "✓ 5.0s"}, + + {Name: "read_file", ArgsSnip: "src/main.go", Result: "⏳"}, + }, + } + + got := buildRichStatus(task, false, "/home/user/my-projects") + + mustContain := []string{ + "Task in progress (3/20)", + + "my-projects", + + "read_file", + + "No errors", + } + + for _, s := range mustContain { + if !strings.Contains(got, s) { + t.Errorf("expected output to contain %q, got:\n%s", s, got) + } + } + + if strings.Contains(got, "Reply to intervene") { + t.Error("non-background task should not have reply prompt") + } + + bgGot := buildRichStatus(task, true, "/home/user/my-projects") + + if !strings.Contains(bgGot, "Reply to intervene") { + t.Error("background task should have reply prompt") + } +} + +func TestBuildRichStatus_ProjectDir(t *testing.T) { + task := &activeTask{ + Iteration: 1, + + MaxIter: 10, + + projectDir: "terra-py-form", + + toolLog: []toolLogEntry{ + {Name: "exec", ArgsSnip: "ls", Result: "✓ 0.1s"}, + }, + } + + got := buildRichStatus(task, false, "/home/user/.picoclaw/workspace") + + if !strings.Contains(got, "terra-py-form") { + t.Errorf("expected projectDir in output, got:\n%s", got) + } + + task2 := &activeTask{ + Iteration: 1, + + MaxIter: 10, + + fileCommonDir: "projects/terra-py-form", + + toolLog: []toolLogEntry{ + {Name: "read_file", ArgsSnip: "src/main.py", Result: "✓ 0.1s"}, + }, + } + + got2 := buildRichStatus(task2, false, "/home/user/.picoclaw/workspace") + + if !strings.Contains(got2, "terra-py-form") { + t.Errorf("expected fileCommonDir basename in output, got:\n%s", got2) + } + + task3 := &activeTask{ + Iteration: 1, + + MaxIter: 10, + + toolLog: []toolLogEntry{ + {Name: "exec", ArgsSnip: "ls", Result: "✓ 0.1s"}, + }, + } + + for _, ws := range []string{"/home/user/my-project/", "/home/user/my-project"} { + got := buildRichStatus(task3, false, ws) + + if !strings.Contains(got, "my-project") { + t.Errorf("workspace %q: expected 'my-project' in output, got:\n%s", ws, got) + } + } +} + +func TestExtractExecProjectDir(t *testing.T) { + tests := []struct { + name string + + cmd string + + want string + }{ + {"cd deep path", "cd /ws/projects/terra-py-form && pytest", "terra-py-form"}, + + {"cd direct subdir", "cd /ws/my-app && make build", "my-app"}, + + {"cd trailing slash", "cd /ws/my-app/ && ls", "my-app"}, + + {"cd to workspace", "cd /ws && ls", "ws"}, + + {"no cd prefix", "pytest tests/", ""}, + + {"empty command", "", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + args := map[string]any{"command": tt.cmd} + + got := extractExecProjectDir(args) + + if got != tt.want { + t.Errorf("extractExecProjectDir(%q) = %q, want %q", tt.cmd, got, tt.want) + } + }) + } +} + +func TestFileParentRelDir(t *testing.T) { + ws := "/home/user/.picoclaw/workspace" + + tests := []struct { + name string + + path string + + want string + }{ + {"deep path", ws + "/projects/terra/src/main.py", "projects/terra/src"}, + + {"direct subdir", ws + "/my-app/README.md", "my-app"}, + + {"workspace root file", ws + "/notes.txt", ""}, + + {"outside workspace", "/tmp/foo.txt", ""}, + + {"trailing slash ws", ws + "/projects/terra/src/main.py", "projects/terra/src"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := fileParentRelDir(tt.path, ws) + + if got != tt.want { + t.Errorf("fileParentRelDir(%q, ws) = %q, want %q", tt.path, got, tt.want) + } + }) + } +} + +func TestCommonDirPrefix(t *testing.T) { + tests := []struct { + name string + + a, b string + + want string + }{ + {"same dir", "projects/terra/src", "projects/terra/src", "projects/terra/src"}, + + {"converge to project", "projects/terra/src", "projects/terra/tests", "projects/terra"}, + + {"converge to top", "projects/terra/src", "projects/other/tests", "projects"}, + + {"no common", "aaa/bbb", "ccc/ddd", ""}, + + {"one is prefix", "projects/terra", "projects/terra/src", "projects/terra"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := commonDirPrefix(tt.a, tt.b) + + if got != tt.want { + t.Errorf("commonDirPrefix(%q, %q) = %q, want %q", tt.a, tt.b, got, tt.want) + } + }) + } +} + +func TestDisplayProjectDir(t *testing.T) { + task1 := &activeTask{projectDir: "my-app", fileCommonDir: "projects/other"} + + if got := displayProjectDir(task1); got != "my-app" { + t.Errorf("expected 'my-app', got %q", got) + } + + task2 := &activeTask{fileCommonDir: "projects/terra-py-form"} + + if got := displayProjectDir(task2); got != "terra-py-form" { + t.Errorf("expected 'terra-py-form', got %q", got) + } + + task3 := &activeTask{fileCommonDir: "my-app"} + + if got := displayProjectDir(task3); got != "my-app" { + t.Errorf("expected 'my-app', got %q", got) + } + + task4 := &activeTask{} + + if got := displayProjectDir(task4); got != "" { + t.Errorf("expected empty, got %q", got) + } +} + +func TestBuildRichStatus_FixedHeight(t *testing.T) { + countLines := func(s string) int { + return strings.Count(s, "\n") + } + + task0 := &activeTask{Iteration: 1, MaxIter: 10} + + lines0 := countLines(buildRichStatus(task0, true, "/ws/p")) + + task1 := &activeTask{ + Iteration: 1, MaxIter: 10, + + toolLog: []toolLogEntry{{Name: "exec", ArgsSnip: "ls", Result: "⏳"}}, + } + + lines1 := countLines(buildRichStatus(task1, true, "/ws/p")) + + task5 := &activeTask{Iteration: 5, MaxIter: 10} + + for i := 0; i < 5; i++ { + task5.toolLog = append(task5.toolLog, toolLogEntry{ + Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s", + }) + } + + lines5 := countLines(buildRichStatus(task5, true, "/ws/p")) + + task5err := &activeTask{Iteration: 5, MaxIter: 10} + + for i := 0; i < 5; i++ { + task5err.toolLog = append(task5err.toolLog, toolLogEntry{ + Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s", + }) + } + + errEntry := toolLogEntry{ + Name: "[3] exec", ArgsSnip: "pytest", Result: "✗ 2.0s", + + ErrDetail: "FAILED test\nExit code: 1", + } + + task5err.lastError = &errEntry + + lines5err := countLines(buildRichStatus(task5err, true, "/ws/p")) + + if lines0 != lines1 || lines1 != lines5 || lines5 != lines5err { + t.Errorf("line counts should be equal: 0=%d, 1=%d, 5=%d, 5+err=%d", + + lines0, lines1, lines5, lines5err) + } +} + +func TestBuildRichStatus_StickyError(t *testing.T) { + errEntry := toolLogEntry{ + Name: "[2] exec", ArgsSnip: "pytest", Result: "✗ 3.2s", + + ErrDetail: "FAILED test_login\nExit code: 1", + } + + task := &activeTask{ + Iteration: 5, + + MaxIter: 10, + + toolLog: []toolLogEntry{ + {Name: "[3] read_file", ArgsSnip: "src/auth.py", Result: "✓ 0.1s"}, + + {Name: "[4] edit_file", ArgsSnip: "src/auth.py", Result: "✓ 0.2s"}, + + {Name: "[5] exec", ArgsSnip: "pytest --retry", Result: "⏳"}, + }, + + lastError: &errEntry, + } + + got := buildRichStatus(task, false, "/ws/p") + + if !strings.Contains(got, "FAILED test_login") { + t.Errorf("expected sticky error detail in error section, got:\n%s", got) + } + + if !strings.Contains(got, "\u274C") { + t.Errorf("expected ❌ error header, got:\n%s", got) + } + + if !strings.Contains(got, "pytest --retry") { + t.Errorf("expected latest entry command, got:\n%s", got) + } +} + +func TestBuildRichStatus_LatestEntryNoInlineResult(t *testing.T) { + longCmd := "uv run pytest tests/hot/test_state_backend_integration.py" + + task := &activeTask{ + Iteration: 2, + + MaxIter: 10, + + toolLog: []toolLogEntry{ + {Name: "exec", ArgsSnip: "ls -la", Result: "\u2713 0.5s"}, + + {Name: "exec", ArgsSnip: longCmd, Result: "\u23F3"}, + }, + } + + got := buildRichStatus(task, false, "/ws/my-project") + + if !strings.Contains(got, "integration.py") { + t.Errorf("latest entry should show filename, got:\n%s", got) + } + + if !strings.Contains(got, " \u23F3") { + t.Errorf("latest entry result should be on indented line, got:\n%s", got) + } + + if !strings.Contains(got, "my-project") { + t.Errorf("should show project name, got:\n%s", got) + } + + lines := strings.Split(got, "\n") + + sepCount := 0 + + for _, l := range lines { + if strings.HasPrefix(l, "\u2501") { + sepCount++ + } + } + + if sepCount != 1 { + t.Errorf("expected exactly 1 separator, got %d in:\n%s", sepCount, got) + } +} + +func TestSanitizeHistoryForProvider_MultiToolCall(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "hello"}, + + {Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{ + {ID: "a", Function: &providers.FunctionCall{Name: "exec"}}, + + {ID: "b", Function: &providers.FunctionCall{Name: "read_file"}}, + }}, + + {Role: "tool", Content: "ok", ToolCallID: "a"}, + + {Role: "tool", Content: "ok", ToolCallID: "b"}, + + {Role: "assistant", Content: "done"}, + } + + got := sanitizeHistoryForProvider(history) + + if len(got) != 5 { + roles := make([]string, len(got)) + + for i, m := range got { + roles[i] = m.Role + } + + t.Fatalf("expected 5 messages, got %d: %v", len(got), roles) + } + + toolCount := 0 + + for _, m := range got { + if m.Role == "tool" { + toolCount++ + } + } + + if toolCount != 2 { + t.Errorf("expected 2 tool results, got %d", toolCount) + } +} + +func setupPlanNudgeTest( + t *testing.T, + plan, content, sessionKey string, +) (*countingMockProvider, func()) { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "agent-nudge-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "test-model", + + MaxTokens: 4096, + + MaxToolIterations: 10, + }, + }, + } + + provider := &countingMockProvider{} + + msgBus := bus.NewMessageBus() + + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.registry.GetDefaultAgent() + + if agent == nil { + t.Fatal("no default agent") + } + + agent.ContextBuilder.WriteMemory(plan) + + ctx, cancel := context.WithTimeout( + context.Background(), 5*time.Second, + ) + + msg := bus.InboundMessage{ + Channel: "test", + + SenderID: "user1", + + ChatID: "chat1", + + Content: content, + + SessionKey: sessionKey, + } + + _, err = al.processMessage(ctx, msg) + + cancel() + + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("processMessage failed: %v", err) + } + + return provider, func() { os.RemoveAll(tmpDir) } +} + +func TestPlanNudge_ForegroundExecution(t *testing.T) { + plan := "# Active Plan\n\n> Task: Test\n" + + "> Status: executing\n> Phase: 1\n\n" + + "## Phase 1: Setup\n- [ ] Step one\n" + + "- [ ] Step two\n\n## Context\n" + + provider, cleanup := setupPlanNudgeTest( + t, plan, "continue working", "nudge-test", + ) + + defer cleanup() + + if provider.calls < 2 { + t.Errorf( + "expected at least 2 provider calls"+ + " (nudge should trigger continuation),"+ + " got %d", provider.calls, + ) + } +} + +func TestPlanNudge_NoNudgeWhenAllStepsComplete(t *testing.T) { + plan := "# Active Plan\n\n> Task: Test\n" + + "> Status: executing\n> Phase: 1\n\n" + + "## Phase 1: Setup\n- [x] Step one\n" + + "- [x] Step two\n\n## Context\n" + + provider, cleanup := setupPlanNudgeTest( + t, plan, "all done", "nudge-test-complete", + ) + + defer cleanup() + + if provider.calls != 1 { + t.Errorf( + "expected exactly 1 provider call"+ + " (no nudge needed), got %d", + provider.calls, + ) + } +} + +func TestPlanNudge_ProgressMessage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-nudge-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "test-model", + + MaxTokens: 4096, + + MaxToolIterations: 10, + }, + }, + } + + var nudgeContent string + + provider := &nudgeCaptureMockProvider{onSecondCall: func(msgs []providers.Message) { + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == "user" { + nudgeContent = msgs[i].Content + + break + } + } + }} + + msgBus := bus.NewMessageBus() + + al := NewAgentLoop(cfg, msgBus, provider) + + agent := al.registry.GetDefaultAgent() + + if agent == nil { + t.Fatal("no default agent") + } + + plan := "# Active Plan\n\n> Task: Test\n> Status: executing\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n- [ ] Step two\n- [ ] Step three\n\n## Context\n" + + agent.ContextBuilder.WriteMemory(plan) + + provider.onFirstCall = func() { + updated := strings.Replace(agent.ContextBuilder.ReadMemory(), "- [ ] Step one", "- [x] Step one", 1) + + agent.ContextBuilder.WriteMemory(updated) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + + defer cancel() + + msg := bus.InboundMessage{ + Channel: "test", + + SenderID: "user1", + + ChatID: "chat1", + + Content: "work on the plan", + + SessionKey: "nudge-progress-test", + } + + _, err = al.processMessage(ctx, msg) + if err != nil { + t.Fatalf("processMessage failed: %v", err) + } + + if !strings.Contains(nudgeContent, "Progress recorded") { + t.Errorf("expected 'Progress recorded' nudge, got %q", nudgeContent) + } + + if !strings.Contains(nudgeContent, "2 unchecked steps remain") { + t.Errorf("expected '2 unchecked steps remain' in nudge, got %q", nudgeContent) + } +} + +type nudgeCaptureMockProvider struct { + calls int + + onFirstCall func() + + onSecondCall func([]providers.Message) +} + +func (m *nudgeCaptureMockProvider) Chat( + _ context.Context, + messages []providers.Message, + _ []providers.ToolDefinition, + _ string, + _ map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + if m.calls == 1 && m.onFirstCall != nil { + m.onFirstCall() + } + if m.calls == 2 && m.onSecondCall != nil { + m.onSecondCall(messages) + } + return &providers.LLMResponse{Content: "ok"}, nil +} + +func (m *nudgeCaptureMockProvider) GetDefaultModel() string { + return "nudge-mock" +} + +func TestConsumeStream_NormalCompletion(t *testing.T) { + ch := make(chan protocoltypes.StreamEvent, 8) + + go func() { + ch <- protocoltypes.StreamEvent{ContentDelta: "Hello "} + + ch <- protocoltypes.StreamEvent{ContentDelta: "world!"} + + ch <- protocoltypes.StreamEvent{ + FinishReason: "stop", + + Usage: &providers.UsageInfo{PromptTokens: 5, CompletionTokens: 2, TotalTokens: 7}, + } + + close(ch) + }() + + ctx, cancel := context.WithCancel(context.Background()) + + defer cancel() + + resp, detected, err := consumeStreamWithRepetitionDetection(ch, cancel, 1000, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if detected { + t.Fatal("expected detected=false for normal content") + } + + if resp.Content != "Hello world!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello world!") + } + + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + + if resp.Usage == nil || resp.Usage.TotalTokens != 7 { + t.Errorf("Usage.TotalTokens = %v, want 7", resp.Usage) + } + + _ = ctx +} + +func TestConsumeStream_DetectsRepetition(t *testing.T) { + ch := make(chan protocoltypes.StreamEvent, 64) + + cancelCalled := false + + ctx, cancel := context.WithCancel(context.Background()) + + wrappedCancel := func() { + cancelCalled = true + + cancel() + } + + repeatedChunk := strings.Repeat("abcdefghij", 50) + + go func() { + for i := 0; i < 6; i++ { + ch <- protocoltypes.StreamEvent{ContentDelta: repeatedChunk} + } + + for i := 0; i < 10; i++ { + ch <- protocoltypes.StreamEvent{ContentDelta: "more data"} + } + + close(ch) + }() + + resp, detected, err := consumeStreamWithRepetitionDetection(ch, wrappedCancel, 1000, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !detected { + t.Fatal("expected repetition detection to trigger") + } + + if !cancelCalled { + t.Error("expected cancelFn to be called") + } + + if len(resp.Content) >= 3000+10*len("more data") { + t.Errorf("Content length = %d, expected less than full output", len(resp.Content)) + } + + _ = ctx +} + +func TestConsumeStream_ToolCallAccumulation(t *testing.T) { + ch := make(chan protocoltypes.StreamEvent, 8) + + go func() { + ch <- protocoltypes.StreamEvent{ + ToolCallDeltas: []protocoltypes.StreamToolCallDelta{ + {Index: 0, ID: "call_1", Name: "test_fn", ArgumentsDelta: `{"ke`}, + }, + } + + ch <- protocoltypes.StreamEvent{ + ToolCallDeltas: []protocoltypes.StreamToolCallDelta{ + {Index: 0, ArgumentsDelta: `y":"val"}`}, + }, + } + + ch <- protocoltypes.StreamEvent{FinishReason: "tool_calls"} + + close(ch) + }() + + _, cancel := context.WithCancel(context.Background()) + + defer cancel() + + resp, detected, err := consumeStreamWithRepetitionDetection(ch, cancel, 1000, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if detected { + t.Fatal("expected no repetition detection for tool calls") + } + + if len(resp.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls)) + } + + if resp.ToolCalls[0].Name != "test_fn" { + t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "test_fn") + } + + if resp.ToolCalls[0].Arguments["key"] != "val" { + t.Errorf("ToolCalls[0].Arguments[key] = %v, want %q", resp.ToolCalls[0].Arguments["key"], "val") + } +} + +func TestConsumeStream_StreamError(t *testing.T) { + ch := make(chan protocoltypes.StreamEvent, 4) + + go func() { + ch <- protocoltypes.StreamEvent{ContentDelta: "partial"} + + ch <- protocoltypes.StreamEvent{Err: fmt.Errorf("read error")} + + close(ch) + }() + + _, cancel := context.WithCancel(context.Background()) + + defer cancel() + + _, _, err := consumeStreamWithRepetitionDetection(ch, cancel, 1000, nil) + + if err == nil { + t.Fatal("expected error, got nil") + } + + if !strings.Contains(err.Error(), "read error") { + t.Errorf("error = %q, want to contain %q", err.Error(), "read error") + } +} + +func TestConsumeStream_OnChunkCallback(t *testing.T) { + ch := make(chan protocoltypes.StreamEvent, 8) + + go func() { + ch <- protocoltypes.StreamEvent{ContentDelta: "Hello "} + + ch <- protocoltypes.StreamEvent{ContentDelta: "world"} + + ch <- protocoltypes.StreamEvent{ContentDelta: "!"} + + ch <- protocoltypes.StreamEvent{FinishReason: "stop"} + + close(ch) + }() + + _, cancel := context.WithCancel(context.Background()) + + defer cancel() + + var chunks []string + + onChunk := func(accumulated, _ string) { + chunks = append(chunks, accumulated) + } + + resp, detected, err := consumeStreamWithRepetitionDetection(ch, cancel, 1000, onChunk) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if detected { + t.Fatal("expected detected=false") + } + + if resp.Content != "Hello world!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello world!") + } + + if len(chunks) != 3 { + t.Fatalf("onChunk called %d times, want 3", len(chunks)) + } + + if chunks[0] != "Hello " { + t.Errorf("chunks[0] = %q, want %q", chunks[0], "Hello ") + } + + if chunks[1] != "Hello world" { + t.Errorf("chunks[1] = %q, want %q", chunks[1], "Hello world") + } + + if chunks[2] != "Hello world!" { + t.Errorf("chunks[2] = %q, want %q", chunks[2], "Hello world!") + } +} + +func TestConsumeStream_OnChunkWithRepetitionDetection(t *testing.T) { + ch := make(chan protocoltypes.StreamEvent, 64) + + cancelCalled := false + + ctx, cancel := context.WithCancel(context.Background()) + + wrappedCancel := func() { + cancelCalled = true + + cancel() + } + + repeatedChunk := strings.Repeat("abcdefghij", 50) + + go func() { + for i := 0; i < 6; i++ { + ch <- protocoltypes.StreamEvent{ContentDelta: repeatedChunk} + } + + for i := 0; i < 10; i++ { + ch <- protocoltypes.StreamEvent{ContentDelta: "more data"} + } + + close(ch) + }() + + var chunkCount int + + onChunk := func(_, _ string) { + chunkCount++ + } + + _, detected, err := consumeStreamWithRepetitionDetection(ch, wrappedCancel, 1000, onChunk) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !detected { + t.Fatal("expected repetition detection to trigger") + } + + if !cancelCalled { + t.Error("expected cancelFn to be called") + } + + if chunkCount == 0 { + t.Error("expected onChunk to be called at least once") + } + + _ = ctx +} + +type modelCapturingMockProvider struct { + mu sync.Mutex + + models []string + + response string +} + +func (m *modelCapturingMockProvider) Chat( + _ context.Context, + _ []providers.Message, + _ []providers.ToolDefinition, + model string, + _ map[string]any, +) (*providers.LLMResponse, error) { + m.mu.Lock() + m.models = append(m.models, model) + m.mu.Unlock() + resp := m.response + if resp == "" { + resp = "ok" + } + return &providers.LLMResponse{Content: resp}, nil +} + +func (m *modelCapturingMockProvider) GetDefaultModel() string { + return "model-capturing-mock" +} + +func setupPlanModelTest( + t *testing.T, + response, memoryContent, userMsg, sessionKey string, +) (*modelCapturingMockProvider, func()) { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "agent-test-planmodel-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "normal-model", + + PlanModel: "plan-model", + + MaxTokens: 4096, + + MaxToolIterations: 2, + }, + }, + } + + msgBus := bus.NewMessageBus() + + provider := &modelCapturingMockProvider{response: response} + + al := NewAgentLoop(cfg, msgBus, provider) + + defaultAgent := al.registry.GetDefaultAgent() + + if defaultAgent == nil { + t.Fatal("No default agent found") + } + + memoryDir := filepath.Join(tmpDir, "memory") + + os.MkdirAll(memoryDir, 0o755) + + memoryPath := filepath.Join(memoryDir, "MEMORY.md") + + if wErr := os.WriteFile( + memoryPath, []byte(memoryContent), 0o644, + ); wErr != nil { + os.RemoveAll(tmpDir) + t.Fatalf("Failed to write MEMORY.md: %v", wErr) + } + + _, err = al.ProcessDirectWithChannel( + context.Background(), + userMsg, + sessionKey, + "test", + "test-chat", + ) + if err != nil { + os.RemoveAll(tmpDir) + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + return provider, func() { os.RemoveAll(tmpDir) } +} + +func TestAgentLoop_PlanModel_UsedDuringInterviewing(t *testing.T) { + mem := "# Active Plan\n\n" + + "> Task: Test plan model\n" + + "> Status: interviewing\n> Phase: 1\n" + + provider, cleanup := setupPlanModelTest( + t, "Plan interview response", mem, + "Hello, plan model test", "test-plan-session", + ) + + defer cleanup() + + provider.mu.Lock() + + defer provider.mu.Unlock() + + if len(provider.models) == 0 { + t.Fatal("Expected at least one Chat call") + } + + if provider.models[0] != "plan-model" { + t.Errorf( + "Expected plan model 'plan-model'"+ + " during interviewing, got %q", + provider.models[0], + ) + } +} + +func TestAgentLoop_PlanModel_NotUsedDuringExecuting(t *testing.T) { + mem := "# Active Plan\n\n\n\n" + + "> Task: Test plan model\n\n" + + "> Status: executing\n\n> Phase: 1\n\n\n\n" + + "## Phase 1: Build\n\n- [ ] Run build\n\n" + + provider, cleanup := setupPlanModelTest( + t, "Executing response", mem, + "Hello, executing test", "test-exec-session", + ) + + defer cleanup() + + provider.mu.Lock() + + defer provider.mu.Unlock() + + if len(provider.models) == 0 { + t.Fatal("Expected at least one Chat call") + } + + if provider.models[0] != "normal-model" { + t.Errorf( + "Expected normal model 'normal-model'"+ + " during executing, got %q", + provider.models[0], + ) + } +} + +func TestAgentLoop_PlanModel_ResolvesProviderForSingleCandidate(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-planmodel-resolve-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + + Model: "MiniMax-M2.5", + + PlanModel: "openai/gpt-5.2", + + MaxTokens: 4096, + + MaxToolIterations: 2, + }, + }, + } + + msgBus := bus.NewMessageBus() + + mainProvider := &modelCapturingMockProvider{response: "wrong provider response"} + + al := NewAgentLoop(cfg, msgBus, mainProvider) + + resolvedProvider := &modelCapturingMockProvider{response: "correct provider response"} + + al.providerCache["openai/gpt-5.2"] = resolvedProvider + + memoryDir := filepath.Join(tmpDir, "memory") + + os.MkdirAll(memoryDir, 0o755) + + memoryPath := filepath.Join(memoryDir, "MEMORY.md") + + memoryContent := "# Active Plan\n\n> Task: Test provider resolution\n> Status: interviewing\n> Phase: 1\n" + + if wErr := os.WriteFile(memoryPath, []byte(memoryContent), 0o644); wErr != nil { + t.Fatalf("Failed to write MEMORY.md: %v", wErr) + } + + _, err = al.ProcessDirectWithChannel( + + context.Background(), + + "Hello, resolve provider test", + + "test-resolve-session", + + "test", + + "test-chat", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + resolvedProvider.mu.Lock() + + defer resolvedProvider.mu.Unlock() + + mainProvider.mu.Lock() + + defer mainProvider.mu.Unlock() + + if len(resolvedProvider.models) == 0 { + t.Fatal("Expected resolved provider to receive Chat call, but it got none") + } + + if resolvedProvider.models[0] != "gpt-5.2" { + t.Errorf("Expected resolved provider to receive model 'gpt-5.2', got %q", resolvedProvider.models[0]) + } + + if len(mainProvider.models) > 0 { + t.Errorf("Expected main provider to receive no Chat calls during plan model phase, got %d calls with models %v", + + len(mainProvider.models), mainProvider.models) + } +} + +func TestPlanCommand_StartClear(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + agent := al.registry.GetDefaultAgent() + + plan := "# Active Plan\n\n> Task: Test task\n> Status: review\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" + + _ = agent.ContextBuilder.WriteMemory(plan) + + agent.Sessions.AddMessage("test-session", "user", "hello") + + agent.Sessions.AddMessage("test-session", "assistant", "world") + + agent.Sessions.SetSummary("test-session", "some summary") + + response, handled := al.handleCommand(context.Background(), bus.InboundMessage{ + Content: "/plan start clear", + + SessionKey: "test-session", + }) + + if !handled { + t.Fatal("expected /plan start clear to be handled") + } + + if !strings.Contains(response, "clean history") { + t.Errorf("expected 'clean history' in response, got %q", response) + } + + if !al.planStartPending { + t.Error("expected planStartPending to be true") + } + + if !al.planClearHistory { + t.Error("expected planClearHistory to be true") + } + + al.planStartPending = false + + clearHistory := al.planClearHistory + + al.planClearHistory = false + + if clearHistory { + agent.Sessions.SetHistory("test-session", nil) + + agent.Sessions.SetSummary("test-session", "") + + _ = agent.Sessions.Save("test-session") + } + + history := agent.Sessions.GetHistory("test-session") + + if len(history) != 0 { + t.Errorf("expected empty history after clear, got %d messages", len(history)) + } + + summary := agent.Sessions.GetSummary("test-session") + + if summary != "" { + t.Errorf("expected empty summary after clear, got %q", summary) + } +} + +func TestPlanCommand_StartWithoutClear_PreservesHistory(t *testing.T) { + al, cleanup := newTestAgentLoopSimple(t) + + defer cleanup() + + agent := al.registry.GetDefaultAgent() + + plan := "# Active Plan\n\n> Task: Test task\n> Status: review\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" + + _ = agent.ContextBuilder.WriteMemory(plan) + + agent.Sessions.AddMessage("test-session", "user", "hello") + + agent.Sessions.AddMessage("test-session", "assistant", "world") + + agent.Sessions.SetSummary("test-session", "some summary") + + response, _ := al.handleCommand(context.Background(), bus.InboundMessage{ + Content: "/plan start", + + SessionKey: "test-session", + }) + + if strings.Contains(response, "clean history") { + t.Errorf("did not expect 'clean history' in response, got %q", response) + } + + if al.planClearHistory { + t.Error("planClearHistory should be false for /plan start without clear") + } + + history := agent.Sessions.GetHistory("test-session") + + if len(history) != 2 { + t.Errorf("expected 2 history messages preserved, got %d", len(history)) + } + + summary := agent.Sessions.GetSummary("test-session") + + if summary != "some summary" { + t.Errorf("expected summary preserved, got %q", summary) + } +} + +func TestFilterInterviewTools(t *testing.T) { + allDefs := []providers.ToolDefinition{ + {Function: protocoltypes.ToolFunctionDefinition{Name: "read_file"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "list_dir"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "web_search"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "web_fetch"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "message"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "edit_file"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "append_file"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "write_file"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "exec"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "logs"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "spawn_subagent"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "skills_search"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "skills_install"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "bg_monitor"}}, + + {Function: protocoltypes.ToolFunctionDefinition{Name: "i2c_transfer"}}, + } + + filtered := filterInterviewTools(allDefs) + + if len(filtered) != 10 { + names := make([]string, len(filtered)) + + for i, d := range filtered { + names[i] = d.Function.Name + } + + t.Errorf("expected 10 allowed tools, got %d: %v", len(filtered), names) + } + + disallowed := map[string]bool{ + "spawnsubagent": true, "skillssearch": true, + + "skillsinstall": true, "bgmonitor": true, "ictransfer": true, + } + + for _, d := range filtered { + norm := tools.NormalizeToolName(d.Function.Name) + + if disallowed[norm] { + t.Errorf("disallowed tool %q should have been filtered out", d.Function.Name) + } + } +} + +func TestBuildStreamingDisplay_ContentOnly(t *testing.T) { + display := buildStreamingDisplay("hello world", "") + + if !strings.HasSuffix(display, " \u2589") { + t.Error("expected cursor suffix") + } + + if strings.Contains(display, "\U0001f9e0") { + t.Error("should not contain brain emoji when no reasoning") + } + + lines := strings.Count(display, "\n") + 1 + + if lines != streamingDisplayLines+1 { + t.Logf("display:\n%s", display) + } +} + +func TestBuildStreamingDisplay_ReasoningOnly(t *testing.T) { + display := buildStreamingDisplay("", "let me think about this") + + if !strings.Contains(display, "\U0001f9e0") { + t.Error("expected brain emoji for reasoning phase") + } + + if !strings.Contains(display, "Thinking...") { + t.Error("expected Thinking... header") + } + + if !strings.HasSuffix(display, " \u2589") { + t.Error("expected cursor suffix") + } +} + +func TestBuildStreamingDisplay_Both(t *testing.T) { + display := buildStreamingDisplay("the answer is 42", "first I considered...") + + if !strings.Contains(display, "\U0001f9e0") { + t.Error("expected brain emoji") + } + + if !strings.Contains(display, "responding") { + t.Error("expected responding header when both present") + } + + if !strings.Contains(display, "the answer is 42") { + t.Error("expected content in display") + } +} + +func TestFormatDurationMs(t *testing.T) { + tests := []struct { + ms int64 + + want string + }{ + {0, "0ms"}, + + {500, "500ms"}, + + {999, "999ms"}, + + {1000, "1.0s"}, + + {1200, "1.2s"}, + + {3500, "3.5s"}, + + {59900, "59.9s"}, + + {60000, "1m"}, + + {61000, "1m1s"}, + + {65000, "1m5s"}, + + {120000, "2m"}, + + {3661000, "61m1s"}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%dms", tt.ms), func(t *testing.T) { + got := formatDurationMs(tt.ms) + + if got != tt.want { + t.Errorf("formatDurationMs(%d) = %q, want %q", tt.ms, got, tt.want) + } + }) + } +} + +func TestFormatSubagentCompletion(t *testing.T) { + tests := []struct { + name string + + label string + + metadata map[string]string + + want string + }{ + { + "no metadata", + + "scout-1", + + nil, + + "📋 scout-1 completed.", + }, + + { + "empty metadata", + + "scout-1", + + map[string]string{}, + + "📋 scout-1 completed.", + }, + + { + "duration and tool calls", + + "scout-1", + + map[string]string{"duration_ms": "3200", "tool_calls": "5"}, + + "📋 scout-1 completed (3.2s, 5 tool calls).", + }, + + { + "single tool call", + + "coder-1", + + map[string]string{"duration_ms": "1200", "tool_calls": "1"}, + + "📋 coder-1 completed (1.2s, 1 tool call).", + }, + + { + "duration only", + + "scout-2", + + map[string]string{"duration_ms": "65000", "tool_calls": "0"}, + + "📋 scout-2 completed (1m5s).", + }, + + { + "tool calls only", + + "scout-3", + + map[string]string{"duration_ms": "0", "tool_calls": "10"}, + + "📋 scout-3 completed (10 tool calls).", + }, + + { + "zero everything", + + "scout-4", + + map[string]string{"duration_ms": "0", "tool_calls": "0"}, + + "📋 scout-4 completed.", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatSubagentCompletion(tt.label, tt.metadata) + + if got != tt.want { + t.Errorf("formatSubagentCompletion(%q, %v) = %q, want %q", tt.label, tt.metadata, got, tt.want) + } + }) + } +} diff --git a/pkg/agent/loop_hooks.go b/pkg/agent/loop_hooks.go new file mode 100644 index 000000000..531a29ac0 --- /dev/null +++ b/pkg/agent/loop_hooks.go @@ -0,0 +1,521 @@ +package agent + +import ( + "context" + "fmt" + "path/filepath" + "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/orch" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// iterationHooks contains callbacks that extend the core LLM iteration loop. +// All fields are initialized to no-op defaults by buildHooks, so callers +// never need nil checks. +type iterationHooks struct { + // OnIterationStart is called at the top of each iteration. + // Returns an optional user-role message to inject (e.g. user intervention). + OnIterationStart func(iteration int) (interventionMsg string) + + // FilterTools is called after building provider tool definitions, + // before the LLM call. Returns a (possibly filtered) slice. + FilterTools func(defs []providers.ToolDefinition) []providers.ToolDefinition + + // SetupStreaming is called before each LLM call to set up streaming + // preview. Returns an onChunk callback and a cleanup function. + SetupStreaming func() (onChunk func(accumulated, reasoning string), cleanup func()) + + // SelectModel overrides the model and candidates for this call. + // Returns empty string to use defaults. + SelectModel func() (model string, candidates []providers.FallbackCandidate) + + // OnPreLLMCall is called just before the LLM call (e.g. orch state reporting). + OnPreLLMCall func() + + // OnNoToolCalls is called when the LLM returns no tool calls. + // Returns an optional nudge message and whether to continue the loop. + OnNoToolCalls func(content string, iteration int) (nudge string, continueLoop bool) + + // FilterToolCalls is called after normalizing tool calls, before execution. + // Returns the filtered calls and an optional rejection message. + FilterToolCalls func(calls []providers.ToolCall) (filtered []providers.ToolCall, rejectionMsg string) + + // OnPreToolExec is called before each tool execution. + // Returns an async callback (may be nil). + OnPreToolExec func(ctx context.Context, tc providers.ToolCall) tools.AsyncCallback + + // OnToolExecDone is called after each tool execution with the result. + OnToolExecDone func(tc providers.ToolCall, result *tools.ToolResult, duration time.Duration) + + // OnToolsProcessed is called after all tool calls in an iteration + // have been logged and their results built. + OnToolsProcessed func(ctx context.Context, iteration int, toolCalls []providers.ToolCall) + + // InjectReminders is called at the end of each iteration to append + // fork-specific reminder messages (task, plan, orch, subagent questions). + InjectReminders func(iteration int, messages *[]providers.Message, lastBlocker string) + + // RefreshSystemPrompt is called at the end of each iteration to + // rebuild the system prompt after tool execution may have changed state. + RefreshSystemPrompt func(messages []providers.Message) +} + +// defaultHooks returns an iterationHooks with all fields set to no-ops. +func defaultHooks() iterationHooks { + return iterationHooks{ + OnIterationStart: func(int) string { return "" }, + FilterTools: func(d []providers.ToolDefinition) []providers.ToolDefinition { return d }, + SetupStreaming: func() (func(string, string), func()) { return nil, nil }, + SelectModel: func() (string, []providers.FallbackCandidate) { return "", nil }, + OnPreLLMCall: func() {}, + OnNoToolCalls: func(string, int) (string, bool) { return "", false }, + FilterToolCalls: func(c []providers.ToolCall) ([]providers.ToolCall, string) { return c, "" }, + OnPreToolExec: func(context.Context, providers.ToolCall) tools.AsyncCallback { return nil }, + OnToolExecDone: func(providers.ToolCall, *tools.ToolResult, time.Duration) {}, + OnToolsProcessed: func(context.Context, int, []providers.ToolCall) {}, + InjectReminders: func(int, *[]providers.Message, string) {}, + RefreshSystemPrompt: func([]providers.Message) {}, + } +} + +// buildHooks constructs the hook set based on the current agent state. +// All fork-specific logic is wired here; the core loop only calls hooks. +func (al *AgentLoop) buildHooks( + agent *AgentInstance, + opts processOptions, + task *activeTask, + planSnapshot string, +) iterationHooks { + h := defaultHooks() + isBackground := opts.TaskID != "" + + // ── Task tracking ── + if task != nil { + h.OnIterationStart = func(iteration int) string { + task.mu.Lock() + task.Iteration = iteration + task.mu.Unlock() + + select { + case msg := <-task.interrupt: + logger.InfoCF("agent", "User intervention injected", + map[string]any{"agent_id": agent.ID, "iteration": iteration}) + return "[User Intervention] " + msg + default: + return "" + } + } + + h.OnToolExecDone = func(tc providers.ToolCall, result *tools.ToolResult, duration time.Duration) { + updateToolLogResult(task, tc, result, duration) + } + } + + // ── Plan mode ── + if planSnapshot != "" { + preUnchecked := -1 + if planSnapshot == "executing" { + preUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]") + } + planMarkNudged := false + + if isPlanPreExecution(planSnapshot) { + h.FilterTools = func(defs []providers.ToolDefinition) []providers.ToolDefinition { + return filterInterviewTools(defs) + } + + h.FilterToolCalls = func(calls []providers.ToolCall) ([]providers.ToolCall, string) { + allowed := calls[:0] + var rejected []string + for _, tc := range calls { + if isToolAllowedDuringInterview(tc.Name, tc.Arguments) { + allowed = append(allowed, tc) + } else { + rejected = append(rejected, tc.Name) + } + } + if len(rejected) > 0 { + logger.InfoCF("agent", "Interview mode: rejected tool calls", + map[string]any{"agent_id": agent.ID, "rejected": rejected}) + } + return allowed, interviewRejectMessage + } + } + + h.OnNoToolCalls = func(content string, iteration int) (string, bool) { + if preUnchecked <= 0 || planMarkNudged || planSnapshot != "executing" { + return "", false + } + curUnchecked := strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]") + if curUnchecked <= 0 { + return "", false + } + planMarkNudged = true + + var nudge string + if curUnchecked == preUnchecked { + nudge = fmt.Sprintf("[System] %d unchecked steps remain in MEMORY.md and "+ + "none were marked [x] during this session. "+ + "If you completed any steps, use edit_file to mark them [x] now. "+ + "If steps are still in progress, continue working on them.", curUnchecked) + } else { + nudge = fmt.Sprintf("[System] Progress recorded. %d unchecked steps remain. "+ + "Continue working on the next step.", curUnchecked) + } + logger.InfoCF("agent", "Nudging plan execution: continue plan steps", + map[string]any{"agent_id": agent.ID, "iteration": iteration, "unchecked": curUnchecked}) + return nudge, true + } + + // Plan model selection + if isPlanPreExecution(planSnapshot) && agent.PlanModel != "" { + h.SelectModel = func() (string, []providers.FallbackCandidate) { + logger.InfoCF("agent", "Using plan model", + map[string]any{"agent_id": agent.ID, "plan_model": agent.PlanModel}) + return agent.PlanModel, agent.PlanCandidates + } + } + } + + // ── Streaming ── + if !constants.IsInternalChannel(opts.Channel) { + h.SetupStreaming = func() (func(string, string), func()) { + return al.setupStreamingHook(opts, task) + } + } + + // ── Orchestration ── + if al.orchReporter != orch.Noop { + h.OnPreLLMCall = func() { + al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "") + } + + // Wrap OnPreToolExec to add orch state reporting + h.OnPreToolExec = func(ctx context.Context, tc providers.ToolCall) tools.AsyncCallback { + al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateToolCall, tc.Name) + return al.buildAsyncCallback(opts, tc.Name) + } + } else { + // Even without orch, we still need async callback + h.OnPreToolExec = func(ctx context.Context, tc providers.ToolCall) tools.AsyncCallback { + return al.buildAsyncCallback(opts, tc.Name) + } + } + + // ── Tool status + session touch ── + if !constants.IsInternalChannel(opts.Channel) && task != nil { + h.OnToolsProcessed = func(ctx context.Context, iteration int, toolCalls []providers.ToolCall) { + al.publishToolStatus(ctx, agent, opts, task, iteration, isBackground, toolCalls) + al.recordSessionTouches(agent, opts, toolCalls) + } + } else { + // Session touch without status publishing + h.OnToolsProcessed = func(ctx context.Context, iteration int, toolCalls []providers.ToolCall) { + al.recordSessionTouches(agent, opts, toolCalls) + } + } + + // ── Reminder injection (task + plan + orch + subagent questions) ── + h.InjectReminders = al.buildReminderInjector(agent, opts, task, planSnapshot) + + // ── System prompt refresh ── + h.RefreshSystemPrompt = func(messages []providers.Message) { + if touchDir := al.sessions.GetTouchDir(opts.SessionKey); touchDir != "" { + agent.ContextBuilder.SetWorkDir(filepath.Join(agent.Workspace, touchDir)) + } + if newPrompt := agent.ContextBuilder.BuildSystemPrompt(); len(messages) > 0 && + messages[0].Content != newPrompt { + messages[0].Content = newPrompt + al.lastSystemPrompt.Store(newPrompt) + al.promptDirty.Store(false) + } + } + + return h +} + +// ── Hook helper implementations ── + +// setupStreamingHook creates a streaming display goroutine and returns +// the onChunk callback and cleanup function. +func (al *AgentLoop) setupStreamingHook(opts processOptions, task *activeTask) (func(string, string), func()) { + type streamUpdate struct{ accumulated, reasoning string } + + streamCh := make(chan streamUpdate, 1) + streamDone := make(chan struct{}) + + ctx := context.Background() // outlive the caller's context for flush + + go func() { + defer close(streamDone) + for up := range streamCh { + display := buildStreamingDisplay(up.accumulated, up.reasoning) + outMsg := bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: display, + } + if opts.Background && opts.TaskID != "" { + outMsg.IsTaskStatus = true + outMsg.TaskID = opts.TaskID + } else { + outMsg.IsStatus = true + } + _ = al.bus.PublishOutbound(ctx, outMsg) + } + }() + + onChunk := func(accumulated, reasoning string) { + if task != nil { + task.streamedChunks = true + } + up := streamUpdate{accumulated, reasoning} + select { + case streamCh <- up: + default: + select { + case <-streamCh: + default: + } + select { + case streamCh <- up: + default: + } + } + } + + cleanup := func() { + close(streamCh) + <-streamDone + } + + return onChunk, cleanup +} + +// buildAsyncCallback creates the async tool callback that publishes +// results as system inbound messages. +func (al *AgentLoop) buildAsyncCallback(opts processOptions, toolName string) tools.AsyncCallback { + return func(_ context.Context, result *tools.ToolResult) { + content := result.ForLLM + if content == "" { + content = result.ForUser + } + if content == "" { + return + } + + logger.InfoCF("agent", "Async tool completed, publishing to conductor", + map[string]any{"tool": toolName, "content_len": len(content), "is_error": result.IsError}) + + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + + _ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{ + Channel: "system", + SenderID: fmt.Sprintf("async:%s", toolName), + ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID), + Content: fmt.Sprintf("Async tool '%s' completed.\n\nResult:\n%s", toolName, content), + }) + } +} + +// updateToolLogResult updates the task's tool log entry with execution result. +func updateToolLogResult(task *activeTask, tc providers.ToolCall, result *tools.ToolResult, duration time.Duration) { + task.mu.Lock() + defer task.mu.Unlock() + + // Walk backward to find the matching pending entry + for i := len(task.toolLog) - 1; i >= 0; i-- { + if task.toolLog[i].Result == "\u23F3" { + if result.IsError || result.Err != nil { + task.toolLog[i].Result = fmt.Sprintf("\u2717 %.1fs", duration.Seconds()) + if result.Err != nil { + task.toolLog[i].ErrDetail = utils.Truncate(result.Err.Error(), 300) + } else if result.ForLLM != "" { + lines := strings.Split(strings.TrimSpace(result.ForLLM), "\n") + start := len(lines) - 3 + if start < 0 { + start = 0 + } + task.toolLog[i].ErrDetail = utils.Truncate( + strings.Join(lines[start:], "\n"), 300) + } + entry := task.toolLog[i] + task.lastError = &entry + } else { + task.toolLog[i].Result = fmt.Sprintf("\u2713 %.1fs", duration.Seconds()) + } + break + } + } +} + +// publishToolStatus adds pending entries to the tool log and publishes +// a rich status update via the message bus. +func (al *AgentLoop) publishToolStatus( + ctx context.Context, + agent *AgentInstance, + opts processOptions, + task *activeTask, + iteration int, + isBackground bool, + toolCalls []providers.ToolCall, +) { + task.mu.Lock() + for _, tc := range toolCalls { + task.toolLog = append(task.toolLog, toolLogEntry{ + Name: fmt.Sprintf("[%d] %s", iteration, tc.Name), + ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace), + Result: "\u23F3", + }) + if task.projectDir == "" && tc.Name == "exec" { + task.projectDir = extractExecProjectDir(tc.Arguments) + } + switch tc.Name { + case "read_file", "write_file", "edit_file", "append_file", "list_dir": + if p, _ := tc.Arguments["path"].(string); p != "" { + if rel := fileParentRelDir(p, agent.Workspace); rel != "" { + if task.fileCommonDir == "" { + task.fileCommonDir = rel + } else { + task.fileCommonDir = commonDirPrefix(task.fileCommonDir, rel) + } + } + } + } + } + task.mu.Unlock() + + statusContent := buildRichStatus(task, isBackground, agent.Workspace) + if isBackground { + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: statusContent, + IsTaskStatus: true, + TaskID: opts.TaskID, + }) + } else { + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: statusContent, + IsStatus: true, + }) + } +} + +// recordSessionTouches records session activity for heartbeat/plan coordination. +func (al *AgentLoop) recordSessionTouches( + agent *AgentInstance, + opts processOptions, + toolCalls []providers.ToolCall, +) { + for _, tc := range toolCalls { + var detectedDir string + if tc.Name == "exec" { + detectedDir = extractExecProjectDir(tc.Arguments) + } + if detectedDir == "" { + switch tc.Name { + case "read_file", "write_file", "edit_file", "append_file", "list_dir": + if p, _ := tc.Arguments["path"].(string); p != "" { + detectedDir = fileParentRelDir(p, agent.Workspace) + } + } + } + if detectedDir != "" { + meta := &TouchMeta{ + ProjectPath: agent.ContextBuilder.GetPlanWorkDir(), + Purpose: utils.Truncate(opts.UserMessage, 80), + Branch: agent.GetWorktreeBranch(opts.SessionKey), + } + if meta.ProjectPath == "" { + meta.ProjectPath = agent.Workspace + } + al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir, meta) + } + } +} + +// buildReminderInjector returns a function that injects all end-of-iteration +// reminder messages: task reminders, plan reminders, orch nudges, and +// pending subagent questions. +func (al *AgentLoop) buildReminderInjector( + agent *AgentInstance, + opts processOptions, + task *activeTask, + planSnapshot string, +) func(int, *[]providers.Message, string) { + lastReminderIdx := -1 + + return func(iteration int, messages *[]providers.Message, lastBlocker string) { + // Task reminder + if shouldInjectReminder(iteration, agent.TaskReminderInterval) && !opts.NoHistory { + if lastReminderIdx >= 0 && lastReminderIdx < len(*messages) { + *messages = append((*messages)[:lastReminderIdx], (*messages)[lastReminderIdx+1:]...) + } + reminderMsg := buildTaskReminder(opts.UserMessage, lastBlocker) + *messages = append(*messages, reminderMsg) + lastReminderIdx = len(*messages) - 1 + logger.DebugCF("agent", "Injected task reminder", + map[string]any{"agent_id": agent.ID, "iteration": iteration, "has_blocker": lastBlocker != ""}) + } + + // Plan reminder + if iteration > 1 && isPlanPreExecution(planSnapshot) { + if reminder, ok := buildPlanReminder(planSnapshot); ok { + *messages = append(*messages, reminder) + logger.DebugCF("agent", "Injected plan reminder", + map[string]any{"agent_id": agent.ID, "iteration": iteration, "plan_status": planSnapshot}) + } + } + + // Orch nudge + if planSnapshot == "executing" && agent.Subagents != nil && agent.Subagents.Enabled { + if reminder, ok := buildOrchReminder(iteration); ok { + *messages = append(*messages, reminder) + logger.DebugCF("agent", "Injected orchestration nudge", + map[string]any{"agent_id": agent.ID, "iteration": iteration}) + } + } + + // Subagent questions/plan reviews + if agent.SubagentMgr != nil { + for _, q := range agent.SubagentMgr.PendingQuestions() { + var content string + switch q.Type { + case "plan_review": + content = fmt.Sprintf( + "[Subagent %s submitted a plan for review]:\n%s\nRespond using the review_subagent_plan tool with task_id=%q.", + q.TaskID, + q.Content, + q.TaskID, + ) + default: + content = fmt.Sprintf( + "[Subagent %s asks]: %s\nRespond using the answer_subagent tool with task_id=%q.", + q.TaskID, q.Content, q.TaskID, + ) + } + *messages = append(*messages, providers.Message{Role: "user", Content: content}) + } + } + + // Tool log trim + if task != nil { + task.mu.Lock() + if len(task.toolLog) > maxToolLogEntries { + task.toolLog = task.toolLog[len(task.toolLog)-maxToolLogEntries:] + } + task.mu.Unlock() + } + } +} diff --git a/pkg/agent/loop_info.go b/pkg/agent/loop_info.go new file mode 100644 index 000000000..d0278b214 --- /dev/null +++ b/pkg/agent/loop_info.go @@ -0,0 +1,281 @@ +package agent + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/skills" + "github.com/sipeed/picoclaw/pkg/stats" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) GetStartupInfo() map[string]any { + info := make(map[string]any) + + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return info + } + + // Tools info + + toolsList := agent.Tools.List() + + toolsMap := map[string]any{ + "count": len(toolsList), + + "names": toolsList, + } + + // Report web search provider if registered + + if t, ok := agent.Tools.Get("web_search"); ok { + if wst, ok := t.(*tools.WebSearchTool); ok { + toolsMap["web_search_provider"] = wst.ProviderName() + } + } + + info["tools"] = toolsMap + + // Skills info + + info["skills"] = agent.ContextBuilder.GetSkillsInfo() + + // Agents info + + info["agents"] = map[string]any{ + "count": len(al.registry.ListAgentIDs()), + + "ids": al.registry.ListAgentIDs(), + } + + return info +} + +// ListSkills returns all available skills from the default agent. + +func (al *AgentLoop) ListSkills() []skills.SkillInfo { + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return nil + } + + return agent.ContextBuilder.ListSkills() +} + +// GetPlanInfo returns plan state from the default agent's memory store. + +func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string, memory string) { + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return false, "", 0, 0, "No agent available.", "" + } + + mem := agent.ContextBuilder.Memory() + + if mem == nil { + return false, "", 0, 0, "No memory store.", "" + } + + hasPlan = mem.HasActivePlan() + + status = mem.GetPlanStatus() + + currentPhase = mem.GetCurrentPhase() + + totalPhases = mem.GetTotalPhases() + + display = mem.FormatPlanDisplay() + + memory = mem.ReadLongTerm() + + return hasPlan, status, currentPhase, totalPhases, display, memory +} + +// GetPlanStatus returns the current plan status ("interviewing", "executing", "review", etc.) or "". + +func (al *AgentLoop) GetPlanStatus() string { + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return "" + } + + return agent.ContextBuilder.GetPlanStatus() +} + +// GetPlanPhases returns structured phase/step data from the default agent's plan. + +func (al *AgentLoop) GetPlanPhases() []PlanPhase { + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return nil + } + + mem := agent.ContextBuilder.Memory() + + if mem == nil { + return nil + } + + return mem.GetPlanPhases() +} + +// GetActiveSessions returns currently active sessions for the mini app API. + +func (al *AgentLoop) GetActiveSessions() []SessionEntry { + return al.sessions.ListActive() +} + +// GetSessionStats returns the current session statistics snapshot, or nil if stats tracking is disabled. + +func (al *AgentLoop) GetSessionStats() *stats.Stats { + if al.stats == nil { + return nil + } + + s := al.stats.GetStats() + + return &s +} + +// GetContextInfo returns the bootstrap file resolution and directory context for the default agent. + +func (al *AgentLoop) GetContextInfo() (workDir, planWorkDir, workspace string, bootstrap []BootstrapFileInfo) { + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return "", "", "", nil + } + + workspace = agent.Workspace + + planWorkDir = agent.ContextBuilder.GetPlanWorkDir() + + // Use the most recent active session's touch_dir (tool-detected project directory) + + if active := al.sessions.ListActive(); len(active) > 0 && active[0].TouchDir != "" { + workDir = active[0].TouchDir + } else { + workDir = agent.ContextBuilder.workDir + } + + bootstrap = agent.ContextBuilder.ResolveBootstrapPaths() + + return workDir, planWorkDir, workspace, bootstrap +} + +// GetSystemPrompt returns the system prompt last sent to the LLM. + +// If the prompt is dirty (state changed since last capture), it rebuilds + +// from current state. Falls back to building if no LLM call has occurred yet. + +func (al *AgentLoop) GetSystemPrompt() string { + if !al.promptDirty.Load() { + if v := al.lastSystemPrompt.Load(); v != nil { + return v.(string) + } + } + + // Rebuild from current state + + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return "" + } + + prompt := agent.ContextBuilder.BuildSystemPrompt() + + al.lastSystemPrompt.Store(prompt) + + al.promptDirty.Store(false) + + return prompt +} + +// 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) + + args := tc.Arguments + + if len(args) == 0 && tc.Function != nil { + args = tc.Function.Arguments + } + + if len(args) > 0 { + argsJSON, _ := json.Marshal(args) + + fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(string(argsJSON), 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(string(tool.Function.Parameters), 200)) + } + } + + sb.WriteString("]") + + return sb.String() +} diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go new file mode 100644 index 000000000..2795db52a --- /dev/null +++ b/pkg/agent/loop_mcp.go @@ -0,0 +1,184 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "context" + "fmt" + "sync" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/mcp" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type mcpRuntime struct { + initOnce sync.Once + mu sync.Mutex + manager *mcp.Manager + initErr error +} + +func (r *mcpRuntime) setManager(manager *mcp.Manager) { + r.mu.Lock() + r.manager = manager + r.initErr = nil + r.mu.Unlock() +} + +func (r *mcpRuntime) setInitErr(err error) { + r.mu.Lock() + r.initErr = err + r.mu.Unlock() +} + +func (r *mcpRuntime) getInitErr() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.initErr +} + +func (r *mcpRuntime) takeManager() *mcp.Manager { + r.mu.Lock() + defer r.mu.Unlock() + manager := r.manager + r.manager = nil + return manager +} + +func (r *mcpRuntime) hasManager() bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.manager != nil +} + +// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct +// agent mode share the same initialization path. +func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error { + if !al.cfg.Tools.IsToolEnabled("mcp") { + return nil + } + + al.mcp.initOnce.Do(func() { + mcpManager := mcp.NewManager() + + defaultAgent := al.registry.GetDefaultAgent() + workspacePath := al.cfg.WorkspacePath() + if defaultAgent != nil && defaultAgent.Workspace != "" { + workspacePath = defaultAgent.Workspace + } + + if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil { + logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available", + map[string]any{ + "error": err.Error(), + }) + if closeErr := mcpManager.Close(); closeErr != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": closeErr.Error(), + }) + } + return + } + + // Register MCP tools for all agents + servers := mcpManager.GetServers() + uniqueTools := 0 + totalRegistrations := 0 + agentIDs := al.registry.ListAgentIDs() + agentCount := len(agentIDs) + + for serverName, conn := range servers { + uniqueTools += len(conn.Tools) + for _, tool := range conn.Tools { + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + mcpTool := tools.NewMCPTool(mcpManager, serverName, tool) + + if al.cfg.Tools.MCP.Discovery.Enabled { + agent.Tools.RegisterHidden(mcpTool) + } else { + agent.Tools.Register(mcpTool) + } + + totalRegistrations++ + logger.DebugCF("agent", "Registered MCP tool", + map[string]any{ + "agent_id": agentID, + "server": serverName, + "tool": tool.Name, + "name": mcpTool.Name(), + }) + } + } + } + logger.InfoCF("agent", "MCP tools registered successfully", + map[string]any{ + "server_count": len(servers), + "unique_tools": uniqueTools, + "total_registrations": totalRegistrations, + "agent_count": agentCount, + }) + + // Initializes Discovery Tools only if enabled by configuration + if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled { + useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25 + useRegex := al.cfg.Tools.MCP.Discovery.UseRegex + + // Fail fast: If discovery is enabled but no search method is turned on + if !useBM25 && !useRegex { + al.mcp.setInitErr(fmt.Errorf( + "tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration", + )) + if closeErr := mcpManager.Close(); closeErr != nil { + logger.ErrorCF("agent", "Failed to close MCP manager", + map[string]any{ + "error": closeErr.Error(), + }) + } + return + } + + ttl := al.cfg.Tools.MCP.Discovery.TTL + if ttl <= 0 { + ttl = 5 // Default value + } + + maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults + if maxSearchResults <= 0 { + maxSearchResults = 5 // Default value + } + + logger.InfoCF("agent", "Initializing tool discovery", map[string]any{ + "bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults, + }) + + for _, agentID := range agentIDs { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + + if useRegex { + agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults)) + } + if useBM25 { + agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults)) + } + } + } + + al.mcp.setManager(mcpManager) + }) + + return al.mcp.getInitErr() +} diff --git a/pkg/agent/loop_orch.go b/pkg/agent/loop_orch.go new file mode 100644 index 000000000..72d9f8df2 --- /dev/null +++ b/pkg/agent/loop_orch.go @@ -0,0 +1,340 @@ +package agent + +import ( + "context" + "fmt" + "strconv" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/constants" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/orch" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/routing" + "github.com/sipeed/picoclaw/pkg/session" +) + +func (al *AgentLoop) reporter() orch.AgentReporter { + if al.orchReporter == nil { + return orch.Noop + } + + return al.orchReporter +} + +// SetOrchReporter wires a Broadcaster as the active reporter. + +// Called from cmd_gateway.go when --orchestration is set. + +// --orchestration なし → 呼ばれない → reporter() は Noop を返す。 + +func (al *AgentLoop) SetOrchReporter(b *orch.Broadcaster) { + al.orchBroadcaster = b + + al.orchReporter = b +} + +// GetOrchBroadcaster returns the concrete Broadcaster for miniapp wiring. + +// Returns nil when orchestration is disabled. + +func (al *AgentLoop) GetOrchBroadcaster() *orch.Broadcaster { + return al.orchBroadcaster +} + +func (al *AgentLoop) notifyStateChange() { + al.promptDirty.Store(true) + + if al.OnStateChange != nil { + al.OnStateChange() + } +} + +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<actual content>" + + 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 + } + + // Inject subagent result into session history without running a full LLM loop. + + // The conductor will see the result on its next turn. This avoids: + + // - Flooding the chat with a response for every subagent completion + + // - Consuming the Telegram "Thinking..." placeholder + + // - Wasting LLM tokens on processing each result individually + + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return "", fmt.Errorf("no default agent for system message") + } + + sessionKey := routing.BuildAgentMainSessionKey(agent.ID) + + historyMsg := fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content) + + // Write as TurnReport to the store for DAG tracking, with legacy fallback. + + subagentSessionKey := routing.BuildSubagentSessionKey(extractTaskID(msg.SenderID)) + + store := agent.Sessions.Store() + + reportTurn := &session.Turn{ + Kind: session.TurnReport, + + OriginKey: subagentSessionKey, + + Author: msg.SenderID, + + Messages: []providers.Message{{Role: "user", Content: historyMsg}}, + } + + if err := store.Append(sessionKey, reportTurn); err != nil { + logger.ErrorCF("agent", "Failed to record report turn, falling back to legacy", + + map[string]any{"error": err.Error()}) + + agent.Sessions.AddMessage(sessionKey, "user", historyMsg) + + agent.Sessions.MarkDirty(sessionKey) + } else { + // Update in-memory cache so conductor sees the message on next turn. + + agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: historyMsg}) + + agent.Sessions.AdvanceStored(sessionKey, 1) + } + + // Send a brief notification (SkipPlaceholder to avoid corrupting status messages) + + label := msg.SenderID + + if idx := strings.LastIndex(label, ":"); idx >= 0 { + label = label[idx+1:] + } + + notification := formatSubagentCompletion(label, msg.Metadata) + + subagentThreadID := 0 + + if al.cfg != nil { + subagentThreadID = al.cfg.Channels.Telegram.SubagentThreadID + } + + notifyChatID := al.withTelegramThread(originChannel, originChatID, subagentThreadID) + + _ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: originChannel, + + ChatID: notifyChatID, + + Content: notification, + + SkipPlaceholder: true, + }) + + logger.InfoCF("agent", "Subagent result injected into session history", + + map[string]any{ + "sender_id": msg.SenderID, + + "session_key": sessionKey, + + "content_len": len(content), + }) + + return "", nil +} + +// extractTaskID extracts the task ID from a sender ID like "subagent:subagent-1". + +func extractTaskID(senderID string) string { + if idx := strings.LastIndex(senderID, ":"); idx >= 0 { + return senderID[idx+1:] + } + + return senderID +} + +// formatSubagentCompletion builds the user-facing notification for a completed subagent. + +// If metadata contains duration_ms and tool_calls it produces e.g.: + +// + +// "📋 scout-1 completed (3.2s, 5 tool calls)." + +// + +// Without metadata it falls back to the plain "📋 scout-1 completed." format. + +func formatSubagentCompletion(label string, metadata map[string]string) string { + if len(metadata) == 0 { + return fmt.Sprintf("📋 %s completed.", label) + } + + durationMs, _ := strconv.ParseInt(metadata["duration_ms"], 10, 64) + + toolCalls, _ := strconv.Atoi(metadata["tool_calls"]) + + if durationMs <= 0 && toolCalls <= 0 { + return fmt.Sprintf("📋 %s completed.", label) + } + + parts := make([]string, 0, 2) + + if durationMs > 0 { + parts = append(parts, formatDurationMs(durationMs)) + } + + if toolCalls > 0 { + if toolCalls == 1 { + parts = append(parts, "1 tool call") + } else { + parts = append(parts, fmt.Sprintf("%d tool calls", toolCalls)) + } + } + + return fmt.Sprintf("📋 %s completed (%s).", label, strings.Join(parts, ", ")) +} + +// formatDurationMs converts milliseconds to a human-readable duration string. + +// Examples: 800 → "0.8s", 1200 → "1.2s", 65000 → "1m5s", 3661000 → "61m1s". + +func formatDurationMs(ms int64) string { + if ms < 1000 { + return fmt.Sprintf("%dms", ms) + } + + totalSec := ms / 1000 + + if totalSec < 60 { + tenths := (ms % 1000) / 100 + + return fmt.Sprintf("%d.%ds", totalSec, tenths) + } + + mins := totalSec / 60 + + sec := totalSec % 60 + + if sec == 0 { + return fmt.Sprintf("%dm", mins) + } + + return fmt.Sprintf("%dm%ds", mins, sec) +} + +// buildOrchReminder returns a reminder to use spawn/subagent during plan execution. + +// Fires on first iteration and every 3rd iteration to reinforce delegation behavior. + +func buildOrchReminder(iteration int) (providers.Message, bool) { + if iteration != 1 && iteration%3 != 0 { + return providers.Message{}, false + } + + content := `[System] ORCHESTRATION mode active. You MUST delegate plan steps to subagents. + +Use spawn (non-blocking, returns immediately) or subagent (blocking, waits for result). + +Do NOT implement steps inline unless they are a single trivial tool call. + + + +To delegate, call the tool with JSON arguments: + + Tool: spawn Arguments: {"task": "...", "preset": "scout", "label": "..."} + + Tool: subagent Arguments: {"task": "...", "label": "..."} + + + +Spawn multiple independent steps in parallel for maximum throughput.` + + return providers.Message{Role: "user", Content: content}, true +} + +func extractPeer(msg bus.InboundMessage) *routing.RoutePeer { + if msg.Peer.Kind == "" { + return nil + } + + peerID := msg.Peer.ID + + if peerID == "" { + if msg.Peer.Kind == "direct" { + peerID = msg.SenderID + } else { + peerID = msg.ChatID + } + } + + return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID} +} + +// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata. + +func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { + parentKind := msg.Metadata["parent_peer_kind"] + + parentID := msg.Metadata["parent_peer_id"] + + if parentKind == "" || parentID == "" { + return nil + } + + return &routing.RoutePeer{Kind: parentKind, ID: parentID} +} diff --git a/pkg/agent/loop_plan.go b/pkg/agent/loop_plan.go new file mode 100644 index 000000000..14cb36a7f --- /dev/null +++ b/pkg/agent/loop_plan.go @@ -0,0 +1,674 @@ +package agent + +import ( + "errors" + "fmt" + "path/filepath" + "strconv" + "strings" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/git" + "github.com/sipeed/picoclaw/pkg/orch" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/tools" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// interviewRejectMessage is the fixed rejection text injected when tool calls + +// are blocked during the interview phase. It is deliberately short to avoid + +// wasting tokens, and ends with a purpose reminder to steer the LLM back. + +const interviewRejectMessage = "[System] Tool call rejected. " + + + "You are in interview mode — ask the user questions and update MEMORY.md. " + + + "Do not execute, edit, or write project files." + +// buildPlanReminder returns a reminder message for plan pre-execution states + +// (interviewing / review) to keep the AI focused on the interview workflow + +// during tool-call iterations. + +func buildPlanReminder(planStatus string) (providers.Message, bool) { + var content string + + switch planStatus { + case "interviewing": + + content = "[System] You are interviewing the user to build a plan. " + + + "Ask clarifying questions and save findings to ## Context in memory/MEMORY.md using edit_file. " + + + "When you have enough information, write ## Phase sections with `- [ ]` checkbox steps, and ## Commands section. " + + + "Then change > Status: to review. Do NOT set it to executing." + + case "review": + + content = "[System] The plan is under review. " + + + "Wait for the user to approve or request changes. Do not proceed with execution." + + default: + + return providers.Message{}, false + } + + return providers.Message{Role: "user", Content: content}, true +} + +func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string, bool) { + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return "No agent configured.", true + } + + if len(args) == 0 { + // /plan — show current plan + + return agent.ContextBuilder.FormatPlanDisplay(), true + } + + sub := args[0] + + switch sub { + case "clear": + + if agent.ContextBuilder.ReadMemory() == "" { + return "No active plan to clear.", true + } + + // Deactivate worktree on plan clear + + if sessionKey != "" { + agent.DeactivateWorktree(sessionKey, "", true) + } + + if err := agent.ContextBuilder.ClearMemory(); err != nil { + return fmt.Sprintf("Error clearing plan: %v", err), true + } + + return "Plan cleared.", true + + case "done": + + if !agent.ContextBuilder.HasActivePlan() { + return "No active plan.", true + } + + if len(args) < 2 { + return "Usage: /plan done <step number>", true + } + + stepNum, err := strconv.Atoi(args[1]) + + if err != nil || stepNum < 1 { + return "Step number must be a positive integer.", true + } + + phase := agent.ContextBuilder.GetCurrentPhase() + + if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil { + return fmt.Sprintf("Error: %v", err), true + } + + return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase), true + + case "add": + + if !agent.ContextBuilder.HasActivePlan() { + return "No active plan.", true + } + + if len(args) < 2 { + return "Usage: /plan add <step description>", true + } + + desc := strings.Join(args[1:], " ") + + phase := agent.ContextBuilder.GetCurrentPhase() + + if err := agent.ContextBuilder.AddStep(phase, desc); err != nil { + return fmt.Sprintf("Error: %v", err), true + } + + return fmt.Sprintf("Added step to phase %d: %s", phase, desc), true + + case "start": + + if !agent.ContextBuilder.HasActivePlan() { + return "No active plan.", true + } + + status := agent.ContextBuilder.GetPlanStatus() + + if status == "executing" { + return "Plan is already executing.", true + } + + if status != "interviewing" && status != "review" { + return fmt.Sprintf("Cannot start from status %q.", status), true + } + + if agent.ContextBuilder.GetTotalPhases() == 0 { + return "Cannot start: no phases defined yet. Complete the interview first.", true + } + + if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil { + return fmt.Sprintf("Error: %v", err), true + } + + al.reporter().ReportStateChange(sessionKey, orch.AgentStatePlanExecuting, "") + + al.planStartPending = true + + clearHistory := len(args) > 1 && args[1] == "clear" + + al.planClearHistory = clearHistory + + if clearHistory { + return "Plan approved. Executing with clean history.", true + } + + return "Plan approved. Executing.", true + + case "next": + + if !agent.ContextBuilder.HasActivePlan() { + return "No active plan.", true + } + + if err := agent.ContextBuilder.AdvancePhase(); err != nil { + return fmt.Sprintf("Error: %v", err), true + } + + phase := agent.ContextBuilder.GetCurrentPhase() + + return fmt.Sprintf("Advanced to phase %d.", phase), true + + case "worktrees": + + return al.handlePlanWorktreesCommand(agent, args[1:]), true + + default: + + // /plan <task description> — start new plan + + // Block if a plan is already active (fast-path error). + + if agent.ContextBuilder.HasActivePlan() { + return "A plan is already active. Use /plan clear first.", true + } + + // Not handled here — let the message flow to the LLM queue. + + // expandPlanCommand will write the seed and rewrite the content. + + return "", false + } +} + +func (al *AgentLoop) handlePlanWorktreesCommand(agent *AgentInstance, args []string) string { + repoRoot := git.FindRepoRoot(agent.Workspace) + + if repoRoot == "" { + return "Workspace is not a git repository." + } + + worktreesDir := filepath.Join(agent.Workspace, ".worktrees") + + sub := "list" + + if len(args) > 0 { + sub = strings.ToLower(strings.TrimSpace(args[0])) + } + + switch sub { + case "", "list": + + items, err := git.ListManagedWorktrees(repoRoot, worktreesDir) + if err != nil { + return fmt.Sprintf("Error listing worktrees: %v", err) + } + + if len(items) == 0 { + return "No active worktrees in workspace/.worktrees." + } + + var sb strings.Builder + + sb.WriteString("Active worktrees\n\n") + + for _, wt := range items { + status := "clean" + + if wt.HasUncommitted { + status = "dirty" + } + + last := "(no commits)" + + if wt.LastCommitHash != "" { + if wt.LastCommitAge != "" { + last = fmt.Sprintf("%s %s (%s)", wt.LastCommitHash, wt.LastCommitSubject, wt.LastCommitAge) + } else { + last = fmt.Sprintf("%s %s", wt.LastCommitHash, wt.LastCommitSubject) + } + } + + fmt.Fprintf(&sb, "- %s\n branch: %s\n status: %s\n last: %s\n", wt.Name, wt.Branch, status, last) + } + + sb.WriteString("\nCommands:\n") + + sb.WriteString("/plan worktrees inspect <name>\n") + + sb.WriteString("/plan worktrees merge <name>\n") + + sb.WriteString("/plan worktrees dispose <name> [force]") + + return sb.String() + + case "inspect": + + if len(args) < 2 { + return "Usage: /plan worktrees inspect <name>" + } + + name := args[1] + + wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name) + if err != nil { + if errors.Is(err, git.ErrInvalidWorktreeName) { + return "Invalid worktree name." + } + + if errors.Is(err, git.ErrWorktreeNotFound) { + return fmt.Sprintf("Worktree %q not found.", name) + } + + return fmt.Sprintf("Error inspecting worktree %q: %v", name, err) + } + + statusOut, _ := git.WorktreeStatusShort(wt.Path) + + diffOut, _ := git.WorktreeDiffStat(wt.Path) + + logOut, _ := git.WorktreeRecentLog(wt.Path, 10) + + if statusOut == "" { + statusOut = "(clean)" + } + + var sb strings.Builder + + fmt.Fprintf(&sb, "Worktree: %s\nBranch: %s\nDirty: %t\n", wt.Name, wt.Branch, wt.HasUncommitted) + + if wt.LastCommitHash != "" { + fmt.Fprintf(&sb, "Last commit: %s %s", wt.LastCommitHash, wt.LastCommitSubject) + + if wt.LastCommitAge != "" { + fmt.Fprintf(&sb, " (%s)", wt.LastCommitAge) + } + + sb.WriteString("\n") + } + + sb.WriteString("\nStatus:\n```\n") + + sb.WriteString(statusOut) + + sb.WriteString("\n```\n") + + if diffOut != "" { + sb.WriteString("\nDiff (stat):\n```\n") + + sb.WriteString(diffOut) + + sb.WriteString("\n```\n") + } + + if logOut != "" { + sb.WriteString("\nRecent commits:\n```\n") + + sb.WriteString(logOut) + + sb.WriteString("\n```") + } + + return sb.String() + + case "merge": + + if len(args) < 2 { + return "Usage: /plan worktrees merge <name>" + } + + name := args[1] + + res, base, err := git.MergeManagedWorktree(repoRoot, worktreesDir, name, "") + if err != nil { + if errors.Is(err, git.ErrInvalidWorktreeName) { + return "Invalid worktree name." + } + + if errors.Is(err, git.ErrWorktreeNotFound) { + return fmt.Sprintf("Worktree %q not found.", name) + } + + return fmt.Sprintf("Error merging worktree %q: %v", name, err) + } + + if res.Conflict { + return fmt.Sprintf("Merge conflict while merging `%s` into `%s`. Merge was aborted.", res.Branch, base) + } + + if res.Merged { + return fmt.Sprintf("Merged `%s` into `%s`.", res.Branch, base) + } + + return fmt.Sprintf("No merge was performed for `%s`.", name) + + case "dispose": + + if len(args) < 2 { + return "Usage: /plan worktrees dispose <name> [force]" + } + + name := args[1] + + force := len(args) > 2 && strings.EqualFold(args[2], "force") + + wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name) + if err != nil { + if errors.Is(err, git.ErrInvalidWorktreeName) { + return "Invalid worktree name." + } + + if errors.Is(err, git.ErrWorktreeNotFound) { + return fmt.Sprintf("Worktree %q not found.", name) + } + + return fmt.Sprintf("Error disposing worktree %q: %v", name, err) + } + + if wt.HasUncommitted && !force { + return fmt.Sprintf( + + "Worktree `%s` has uncommitted changes. Re-run with `/plan worktrees dispose %s force` to confirm.", + + name, + + name, + ) + } + + res, err := git.DisposeManagedWorktree(repoRoot, worktreesDir, name, "") + if err != nil { + return fmt.Sprintf("Error disposing worktree %q: %v", name, err) + } + + parts := []string{fmt.Sprintf("Disposed worktree `%s` (branch `%s`).", name, res.Branch)} + + if res.AutoCommitted { + parts = append(parts, "Uncommitted changes were auto-committed.") + } + + if res.CommitsAhead > 0 { + parts = append(parts, fmt.Sprintf("Branch has %d unique commit(s); branch was kept.", res.CommitsAhead)) + } + + if res.BranchDeleted { + parts = append(parts, "Branch was deleted (no unique commits).") + } + + return strings.Join(parts, " ") + } + + return "Usage: /plan worktrees [list|inspect <name>|merge <name>|dispose <name> [force]]" +} + +// isPlanPreExecution returns true if the plan is in a pre-execution state + +// (interviewing or review) where tool restrictions and iteration caps apply. + +func isPlanPreExecution(status string) bool { + return status == "interviewing" || status == "review" +} + +// interviewAllowedTools is the single source of truth for tool names that may + +// be sent to the LLM (and subsequently invoked) during the interview phase. + +// filterInterviewTools uses this to strip tool *definitions* before the LLM call, + +// while isToolAllowedDuringInterview adds argument-level checks as a second gate. + +var interviewAllowedTools = map[string]bool{ + "readfile": true, + + "listdir": true, + + "websearch": true, + + "webfetch": true, + + "message": true, + + "editfile": true, + + "appendfile": true, + + "writefile": true, + + "exec": true, + + "logs": true, +} + +// filterInterviewTools removes tool definitions that are not in the + +// interviewAllowedTools whitelist, reducing token usage and preventing the + +// LLM from attempting disallowed tool calls during the interview phase. + +func filterInterviewTools(defs []providers.ToolDefinition) []providers.ToolDefinition { + filtered := make([]providers.ToolDefinition, 0, len(defs)) + + for _, d := range defs { + if interviewAllowedTools[tools.NormalizeToolName(d.Function.Name)] { + filtered = append(filtered, d) + } + } + + return filtered +} + +// isToolAllowedDuringInterview checks whether a tool call is permitted while the + +// plan is in a pre-execution state. Uses the shared interviewAllowedTools map for + +// name-level gating, then applies argument-level constraints for write-type tools + +// (MEMORY.md only) and exec (read-only commands only). + +func isToolAllowedDuringInterview(toolName string, args map[string]any) bool { + norm := tools.NormalizeToolName(toolName) + + if !interviewAllowedTools[norm] { + return false + } + + // Argument-level constraints + + switch norm { + case "editfile", "appendfile", "writefile": + + path, _ := args["path"].(string) + + return strings.HasSuffix(path, "MEMORY.md") + + case "exec": + + cmd, _ := args["command"].(string) + + return isReadOnlyCommand(cmd) + } + + return true +} + +// isReadOnlyCommand returns true when cmd is a safe, read-only shell command + +// that an LLM may run during the interview phase. + +func isReadOnlyCommand(cmd string) bool { + cmd = strings.TrimSpace(cmd) + + if cmd == "" { + return false + } + + // Reject write operators anywhere in the command + + for _, op := range []string{">", ">>", "| tee "} { + if strings.Contains(cmd, op) { + return false + } + } + + // Reject path traversal (defense in depth; ExecTool.guardCommand also enforces workspace restriction) + + if strings.Contains(cmd, "..") { + return false + } + + // Block absolute paths in arguments (allow "cd /path && cmd" which is stripped later) + + for _, field := range strings.Fields(cmd) { + if strings.HasPrefix(field, "/") && !strings.HasPrefix(cmd, "cd ") { + return false + } + } + + // Strip "cd /path &&" prefix (LLM habit) + + if strings.HasPrefix(cmd, "cd ") { + if idx := strings.Index(cmd, "&&"); idx >= 0 { + cmd = strings.TrimSpace(cmd[idx+2:]) + } + } + + fields := strings.Fields(cmd) + + if len(fields) == 0 { + return false + } + + first := filepath.Base(fields[0]) + + switch first { + case "find", "ls", "cat", "head", "tail", "grep", "rg", + + "tree", "wc", "file", "which", "pwd", + + "uname", "df", "du", "stat", "realpath", "dirname", + + "basename", "date": + + return true + } + + return false +} + +// isWriteTool returns true if the tool can modify files. + +func isWriteTool(name string) bool { + switch tools.NormalizeToolName(name) { + case "writefile", "editfile", "appendfile", "exec": + + return true + } + + return false +} + +// expandPlanCommand detects "/plan <task>" (new plan start) and: + +// - writes the interview seed to MEMORY.md + +// - rewrites the message content for the LLM + +// - returns a compact form for session history + +// + +// This follows the same pattern as expandSkillCommand: the message is + +// rewritten before reaching the LLM, so the AI sees the task description + +// while the system prompt contains the interview guide. + +func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) { + content := strings.TrimSpace(msg.Content) + + if !strings.HasPrefix(content, "/plan ") { + return "", "", false + } + + task := strings.TrimSpace(content[6:]) // len("/plan ") == 6 + + if task == "" { + return "", "", false + } + + // Known subcommands are handled by handlePlanCommand (fast path). + + firstWord := strings.Fields(task)[0] + + switch firstWord { + case "clear", "done", "add", "start", "next", "worktrees": + + return "", "", false + } + + agent := al.registry.GetDefaultAgent() + + if agent == nil { + return "", "", false + } + + // If a plan is already active, don't expand — handleCommand will + + // catch it and return the error on the fast path. + + if agent.ContextBuilder.HasActivePlan() { + return "", "", false + } + + // Write the interview seed + + seed := BuildInterviewSeed(task, agent.Workspace) + + if err := agent.ContextBuilder.WriteMemory(seed); err != nil { + return "", "", false + } + + al.notifyStateChange() + + // Expanded: the task description goes to LLM. + + // The system prompt already contains the interview guide. + + expanded = task + + compact = fmt.Sprintf("[Plan: %s]", utils.Truncate(task, 80)) + + return expanded, compact, true +} diff --git a/pkg/agent/loop_session.go b/pkg/agent/loop_session.go new file mode 100644 index 000000000..dfbcfee12 --- /dev/null +++ b/pkg/agent/loop_session.go @@ -0,0 +1,393 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// sessionSemaphore is a per-session mutex using a buffered channel. + +type sessionSemaphore struct { + ch chan struct{} +} + +func newSessionSemaphore() *sessionSemaphore { + s := &sessionSemaphore{ch: make(chan struct{}, 1)} + + s.ch <- struct{}{} // initially unlocked + + return s +} + +func (al *AgentLoop) gcLoop() { + ticker := time.NewTicker(30 * time.Minute) + + defer ticker.Stop() + + for { + select { + case <-ticker.C: + + al.gcSessionLocks() + + case <-al.done: + + return + } + } +} + +// gcSessionLocks removes unlocked (idle) sessionSemaphore entries from the map. + +func (al *AgentLoop) gcSessionLocks() { + al.sessionLocks.Range(func(key, val any) bool { + sem := val.(*sessionSemaphore) + + select { + case <-sem.ch: + + // Was unlocked — safe to remove + + al.sessionLocks.Delete(key) + + default: + + // Currently locked — in use, keep + } + + return true + }) +} + +func (al *AgentLoop) acquireSessionLock(ctx context.Context, sessionKey string) bool { + val, _ := al.sessionLocks.LoadOrStore(sessionKey, newSessionSemaphore()) + + sem := val.(*sessionSemaphore) + + select { + case <-sem.ch: + + return true + + case <-ctx.Done(): + + return false + } +} + +// releaseSessionLock releases the per-session semaphore. + +func (al *AgentLoop) releaseSessionLock(sessionKey string) { + if val, ok := al.sessionLocks.Load(sessionKey); ok { + sem := val.(*sessionSemaphore) + + sem.ch <- struct{}{} + } +} + +func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { + newHistory := agent.Sessions.GetHistory(sessionKey) + + tokenEstimate := al.estimateTokens(newHistory) + + threshold := agent.ContextWindow * 75 / 100 + + if len(newHistory) > 20 || tokenEstimate > threshold { + summarizeKey := agent.ID + ":" + sessionKey + + if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { + go func() { + defer al.summarizing.Delete(summarizeKey) + + logger.InfoCF("agent", "Memory threshold reached, optimizing conversation history", + + map[string]any{ + "session_key": sessionKey, + + "history_len": len(newHistory), + + "token_estimate": tokenEstimate, + }) + + al.summarizeSession(agent, sessionKey) + }() + } + } +} + +// forceCompression aggressively reduces context when the limit is hit. + +// It drops the oldest 50% of messages (keeping system prompt and last user message). + +func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) { + history := agent.Sessions.GetHistory(sessionKey) + + if len(history) <= 4 { + return + } + + // Keep system prompt (usually [0]) and the very last message (user's trigger) + + // We want to drop the oldest half of the *conversation* + + // Assuming [0] is system, [1:] is conversation + + conversation := history[1 : len(history)-1] + + if len(conversation) == 0 { + return + } + + // Helper to find the mid-point of the conversation + + mid := len(conversation) / 2 + + // New history structure: + + // 1. System Prompt (with compression note appended) + + // 2. Second half of conversation + + // 3. Last message + + droppedCount := mid + + keptConversation := conversation[mid:] + + newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1) + + // Append compression note to the original system prompt instead of adding a new system message + + // This avoids having two consecutive system messages which some APIs (like Zhipu) reject + + compressionNote := fmt.Sprintf( + + "\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]", + + droppedCount, + ) + + enhancedSystemPrompt := history[0] + + enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote + + newHistory = append(newHistory, enhancedSystemPrompt) + + newHistory = append(newHistory, keptConversation...) + + newHistory = append(newHistory, history[len(history)-1]) // Last message + + // Update session + + agent.Sessions.SetHistory(sessionKey, newHistory) + + agent.Sessions.Save(sessionKey) + + logger.WarnCF("agent", "Forced compression executed", map[string]any{ + "session_key": sessionKey, + + "dropped_msgs": droppedCount, + + "new_count": len(newHistory), + }) +} + +func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + + defer cancel() + + history := agent.Sessions.GetHistory(sessionKey) + + summary := agent.Sessions.GetSummary(sessionKey) + + // Keep last 4 messages for continuity + + if len(history) <= 4 { + return + } + + toSummarize := history[:len(history)-4] + + // Oversized Message Guard + + maxMessageTokens := agent.ContextWindow / 2 + + validMessages := make([]providers.Message, 0) + + omitted := false + + for _, m := range toSummarize { + if m.Role != "user" && m.Role != "assistant" { + continue + } + + msgTokens := len(m.Content) / 2 + + if msgTokens > maxMessageTokens { + omitted = true + + continue + } + + validMessages = append(validMessages, m) + } + + if len(validMessages) == 0 { + return + } + + // Multi-Part Summarization + + var finalSummary string + + if len(validMessages) > 10 { + mid := len(validMessages) / 2 + + part1 := validMessages[:mid] + + part2 := validMessages[mid:] + + s1, _ := al.summarizeBatch(ctx, agent, part1, "") + + s2, _ := al.summarizeBatch(ctx, agent, part2, "") + + mergePrompt := fmt.Sprintf( + + "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", + + s1, + + s2, + ) + + resp, err := agent.Provider.Chat( + + ctx, + + []providers.Message{{Role: "user", Content: mergePrompt}}, + + nil, + + agent.Model, + + map[string]any{ + "max_tokens": 1024, + + "temperature": 0.3, + + "prompt_cache_key": agent.ID, + }, + ) + + if err == nil { + finalSummary = resp.Content + } else { + finalSummary = s1 + " " + s2 + } + } else { + finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) + } + + if omitted && finalSummary != "" { + finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" + } + + if finalSummary != "" { + if err := agent.Sessions.CompactOldTurns(sessionKey, 4, finalSummary); err != nil { + logger.ErrorCF("agent", "CompactOldTurns failed, falling back", + + map[string]any{"error": err.Error()}) + + agent.Sessions.SetSummary(sessionKey, finalSummary) + + agent.Sessions.TruncateHistory(sessionKey, 4) + + agent.Sessions.Save(sessionKey) + } + } +} + +// summarizeBatch summarizes a batch of messages. + +func (al *AgentLoop) summarizeBatch( + ctx context.Context, + + agent *AgentInstance, + + batch []providers.Message, + + existingSummary string, +) (string, error) { + var sb strings.Builder + + sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") + + if agent.ContextBuilder.HasActivePlan() { + sb.WriteString("Note: Active plan in MEMORY.md. Preserve plan progress references.\n") + } + + if existingSummary != "" { + sb.WriteString("Existing context: ") + + sb.WriteString(existingSummary) + + sb.WriteString("\n") + } + + sb.WriteString("\nCONVERSATION:\n") + + for _, m := range batch { + fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) + } + + prompt := sb.String() + + response, err := agent.Provider.Chat( + + ctx, + + []providers.Message{{Role: "user", Content: prompt}}, + + nil, + + agent.Model, + + map[string]any{ + "max_tokens": 1024, + + "temperature": 0.3, + + "prompt_cache_key": agent.ID, + }, + ) + if err != nil { + return "", err + } + + return response.Content, nil +} + +// estimateTokens estimates the number of tokens in a message list. + +// Uses a safe heuristic of 2.5 characters per token to account for CJK and other + +// overheads better than the previous 3 chars/token. + +func (al *AgentLoop) estimateTokens(messages []providers.Message) int { + totalChars := 0 + + for _, m := range messages { + totalChars += utf8.RuneCountInString(m.Content) + } + + // 2.5 chars per token = totalChars * 2 / 5 + + return totalChars * 2 / 5 +} diff --git a/pkg/agent/loop_streaming.go b/pkg/agent/loop_streaming.go new file mode 100644 index 000000000..2b934856e --- /dev/null +++ b/pkg/agent/loop_streaming.go @@ -0,0 +1,317 @@ +package agent + +import ( + "context" + "encoding/json" + "errors" + "strings" + "time" + "unicode/utf8" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" + "github.com/sipeed/picoclaw/pkg/utils" +) + +func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) { + if reasoningContent == "" || channelName == "" || channelID == "" { + return + } + + // Check context cancellation before attempting to publish, + + // since PublishOutbound's select may race between send and ctx.Done(). + + if ctx.Err() != nil { + return + } + + // Use a short timeout so the goroutine does not block indefinitely when + + // the outbound bus is full. Reasoning output is best-effort; dropping it + + // is acceptable to avoid goroutine accumulation. + + pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second) + + defer pubCancel() + + if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: channelName, + + ChatID: channelID, + + Content: reasoningContent, + }); err != nil { + // Treat context.DeadlineExceeded / context.Canceled as expected + + // (bus full under load, or parent canceled). Check the error + + // itself rather than ctx.Err(), because pubCtx may time out + + // (5 s) while the parent ctx is still active. + + // Also treat ErrBusClosed as expected — it occurs during normal + + // shutdown when the bus is closed before all goroutines finish. + + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) || + + errors.Is(err, bus.ErrBusClosed) { + logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{ + "channel": channelName, + + "error": err.Error(), + }) + } else { + logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{ + "channel": channelName, + + "error": err.Error(), + }) + } + } +} + +// streamingReasoningLines is the number of lines reserved for reasoning + +// in the streaming display. The remaining lines go to content. + +const streamingReasoningLines = 6 + +// buildStreamingDisplay builds a fixed-height status bubble for streaming. + +// + +// Layout when reasoning is active (reasoning only or both): + +// + +// 🧠 Thinking... + +// ━━━━━━━━━━ + +// <reasoning tail — streamingReasoningLines lines> + +// ━━━━━━━━━━ + +// <content tail — remaining lines> (or blank if content is empty) + +// █ + +// + +// Layout when no reasoning (content only): + +// + +// <content tail — streamingDisplayLines lines> + +// █ + +func buildStreamingDisplay(content, reasoning string) string { + if reasoning == "" { + // No reasoning — full window for content. + + return utils.TailPad(content, streamingDisplayLines, maxEntryLineWidth) + " \u2589" + } + + var sb strings.Builder + + // Header + + if content == "" { + sb.WriteString("\U0001f9e0 Thinking...\n") + } else { + sb.WriteString("\U0001f9e0 Thought, now responding...\n") + } + + sb.WriteString(statusSeparator) + + // Reasoning window + + headerLines := 2 // header + separator + + footerLines := 1 // separator before content + + contentLines := streamingDisplayLines - headerLines - footerLines - streamingReasoningLines + + if contentLines < 3 { + contentLines = 3 + } + + rLines := streamingDisplayLines - headerLines - footerLines - contentLines + + sb.WriteString(utils.TailPad(reasoning, rLines, maxEntryLineWidth)) + + sb.WriteByte('\n') + + sb.WriteString(statusSeparator) + + // Content window (may be blank padding if content hasn't started) + + sb.WriteString(utils.TailPad(content, contentLines, maxEntryLineWidth)) + + sb.WriteString(" \u2589") + + return sb.String() +} + +// consumeStreamWithRepetitionDetection reads StreamEvents from ch, accumulates + +// content and tool calls, and runs repetition detection every checkInterval runes. + +// If repetition is detected, cancelFn is called to abort the HTTP request and + +// the function returns the partial response with detected=true. + +func consumeStreamWithRepetitionDetection( + ch <-chan protocoltypes.StreamEvent, + + cancelFn context.CancelFunc, + + checkInterval int, + + onChunk func(content, reasoning string), +) (*providers.LLMResponse, bool, error) { + var content strings.Builder + + var reasoning strings.Builder + + var toolCalls []streamToolCallAcc + + var finishReason string + + var usage *providers.UsageInfo + + runesSinceLastCheck := 0 + + for ev := range ch { + if ev.Err != nil { + return nil, false, ev.Err + } + + updated := false + + if ev.ContentDelta != "" { + content.WriteString(ev.ContentDelta) + + runesSinceLastCheck += utf8.RuneCountInString(ev.ContentDelta) + + updated = true + } + + if ev.ReasoningDelta != "" { + reasoning.WriteString(ev.ReasoningDelta) + + updated = true + } + + if updated && onChunk != nil { + onChunk(content.String(), reasoning.String()) + } + + if ev.FinishReason != "" { + finishReason = ev.FinishReason + } + + if ev.Usage != nil { + usage = ev.Usage + } + + for _, tc := range ev.ToolCallDeltas { + for len(toolCalls) <= tc.Index { + toolCalls = append(toolCalls, streamToolCallAcc{}) + } + + if tc.ID != "" { + toolCalls[tc.Index].id = tc.ID + } + + if tc.Name != "" { + toolCalls[tc.Index].name = tc.Name + } + + toolCalls[tc.Index].args.WriteString(tc.ArgumentsDelta) + } + + // Run repetition detection periodically on accumulated content. + + if runesSinceLastCheck >= checkInterval && content.Len() > 2000 { + runesSinceLastCheck = 0 + + if utils.DetectRepetitionLoop(content.String()) { + cancelFn() + + // Drain remaining events so the producer goroutine can exit. + + for range ch { + } + + resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage) + + return resp, true, nil + } + } + } + + resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage) + + return resp, false, nil +} + +// streamToolCallAcc accumulates streamed tool call fragments. + +type streamToolCallAcc struct { + id string + + name string + + args strings.Builder +} + +// buildAccumulatedResponse constructs an LLMResponse from accumulated stream data. + +func buildAccumulatedResponse( + content, reasoning string, + + toolCalls []streamToolCallAcc, + + finishReason string, + + usage *providers.UsageInfo, +) *providers.LLMResponse { + resp := &providers.LLMResponse{ + Content: content, + + Reasoning: reasoning, + + FinishReason: finishReason, + + Usage: usage, + } + + for _, tc := range toolCalls { + arguments := make(map[string]any) + + argStr := tc.args.String() + + if argStr != "" { + if err := json.Unmarshal([]byte(argStr), &arguments); err != nil { + arguments["raw"] = argStr + } + } + + resp.ToolCalls = append(resp.ToolCalls, providers.ToolCall{ + ID: tc.id, + + Name: tc.name, + + Arguments: arguments, + }) + } + + return resp +} diff --git a/pkg/agent/loop_task.go b/pkg/agent/loop_task.go new file mode 100644 index 000000000..2f081e6fc --- /dev/null +++ b/pkg/agent/loop_task.go @@ -0,0 +1,701 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strconv" + "strings" + "sync" + "time" + "unicode" + "unicode/utf8" + + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/utils" +) + +// activeTask tracks a running agent task for live status and intervention. + +type activeTask struct { + Description string + + Result string // LLM response summary for completion notification + + Iteration int + + MaxIter int + + StartedAt time.Time + + cancel context.CancelFunc + + interrupt chan string // buffered 1, for user message injection + + toolLog []toolLogEntry + + lastError *toolLogEntry // sticky: most recent error, persists across iterations + + projectDir string // detected from exec cd target (authoritative) + + fileCommonDir string // LCP of file paths relative to workspace (fallback) + + streamedChunks bool // true after onChunk fires at least once + + messageContent string // last content sent by the message tool (for inclusion in completion) + + mu sync.Mutex +} + +// toolLogEntry records a single tool call for the live terminal view. + +type toolLogEntry struct { + Name string + + ArgsSnip string // first ~80 chars of args + + Result string // "✓ 4.9s" or "✗ 3.2s" + + ErrDetail string // non-empty on error — e.g. "Exit code: exit status 1" +} + +// maxToolLogEntries limits the sliding window of tool log entries + +// kept in memory and displayed in status messages. + +const maxToolLogEntries = 5 + +// Task reminder constants and helpers. + +const ( + taskReminderMaxChars = 500 + + blockerMaxChars = 200 +) + +func shouldInjectReminder(iteration, interval int) bool { + if interval <= 0 { + return false + } + + return iteration > 1 && iteration%interval == 0 +} + +func buildTaskReminder(userMessage string, lastBlocker string) providers.Message { + truncatedTask := utils.Truncate(userMessage, taskReminderMaxChars) + + var content string + + if lastBlocker != "" { + truncatedBlocker := utils.Truncate(lastBlocker, blockerMaxChars) + + content = fmt.Sprintf( + "[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nLast blocker:\n---\n%s\n---\nFix the blocker if essential, or find an alternative. If all steps are complete, move on.", + truncatedTask, + truncatedBlocker, + ) + } else { + content = fmt.Sprintf( + "[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nIf all steps of the original task are complete, move on. Otherwise, continue with the next step.", + truncatedTask, + ) + } + + return providers.Message{ + Role: "user", + + Content: content, + } +} + +// cdPrefixPattern matches "cd /some/path && " at the start of a shell command. + +// Group 1 captures the target directory path. + +var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`) + +// optFlagPattern matches option flags like --verbose, -v, --timeout=60, -q. + +// Only standalone flags are removed; flags whose value is the next positional + +// argument (e.g. "-A 20") are kept because removing them would lose context. + +var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`) + +// extractExecProjectDir extracts the basename of an exec cd target. + +// Returns "" if the command has no cd prefix. + +func extractExecProjectDir(args map[string]any) string { + cmd, _ := args["command"].(string) + + if cmd == "" { + return "" + } + + m := cdPrefixPattern.FindStringSubmatch(cmd) + + if len(m) < 2 { + return "" + } + + cdPath := strings.TrimRight(m[1], "/\\") + + if idx := strings.LastIndex(cdPath, "/"); idx >= 0 { + return cdPath[idx+1:] + } + + if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 { + return cdPath[idx+1:] + } + + return cdPath +} + +// fileParentRelDir returns the parent directory of a file path, relative to + +// workspace. Returns "" if the path is not under workspace or has no parent. + +func fileParentRelDir(filePath, workspace string) string { + ws := strings.TrimRight(workspace, "/\\") + + if ws == "" { + return "" + } + + rest := strings.TrimPrefix(filePath, ws) + + if rest == filePath { + return "" // not under workspace + } + + rest = strings.TrimLeft(rest, "/\\") + + // Remove the filename — keep only the directory part + + if idx := strings.LastIndexAny(rest, "/\\"); idx >= 0 { + return rest[:idx] + } + + return "" // file is directly under workspace, no meaningful dir +} + +// commonDirPrefix computes the longest common directory prefix of two + +// slash-separated paths. Returns "" if there is no common component. + +func commonDirPrefix(a, b string) string { + partsA := strings.Split(a, "/") + + partsB := strings.Split(b, "/") + + n := len(partsA) + + if len(partsB) < n { + n = len(partsB) + } + + common := 0 + + for i := 0; i < n; i++ { + if partsA[i] != partsB[i] { + break + } + + common = i + 1 + } + + if common == 0 { + return "" + } + + return strings.Join(partsA[:common], "/") +} + +// displayProjectDir returns the project directory name for status display. + +// Prefers the authoritative exec-based projectDir; falls back to the + +// basename of the file-based common directory. + +func displayProjectDir(task *activeTask) string { + if task.projectDir != "" { + return task.projectDir + } + + if task.fileCommonDir != "" { + dir := task.fileCommonDir + + if idx := strings.LastIndex(dir, "/"); idx >= 0 { + return dir[idx+1:] + } + + return dir + } + + return "" +} + +// buildArgsSnippet produces a human-friendly snippet for the tool log. + +// For exec: extracts the command and strips the leading "cd <workspace> && ". + +// For file tools: extracts the path and strips the workspace prefix. + +// Falls back to raw JSON truncation. + +func buildArgsSnippet(toolName string, args map[string]any, workspace string) string { + switch toolName { + case "exec": + + cmd, _ := args["command"].(string) + + if cmd == "" { + break + } + + cmd = cdPrefixPattern.ReplaceAllString(cmd, "") + + cmd = optFlagPattern.ReplaceAllString(cmd, "") + + return utils.Truncate(cmd, 80) + + case "read_file", "write_file", "edit_file", "append_file", "list_dir": + + path, _ := args["path"].(string) + + if path == "" { + break + } + + if workspace != "" { + path = strings.TrimPrefix(path, workspace) + + path = strings.TrimPrefix(path, "/") + } + + // Prioritize filename: if path is too long, show "…/filename" + + const maxPath = 60 + + if runes := []rune(path); len(runes) > maxPath { + // Find last slash to extract filename + + if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 { + filename := path[lastSlash:] // includes "/" + + dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…" + + if dirBudget > 0 { + dir := []rune(path[:lastSlash]) + + if len(dir) > dirBudget { + dir = dir[:dirBudget] + } + + path = string(dir) + "\u2026" + filename + } else { + path = "\u2026" + filename + } + } else { + path = utils.Truncate(path, maxPath) + } + } + + return path + } + + // Default: raw JSON truncated + + argsJSON, _ := json.Marshal(args) + + return utils.Truncate(string(argsJSON), 80) +} + +// maxEntryLineWidth is the max rune count for a single-line log entry. + +// Telegram chat bubbles on mobile are roughly 40-45 chars wide. + +const maxEntryLineWidth = 42 + +// isFileToolEntry returns true if the entry name contains a file-operation tool. + +func isFileToolEntry(name string) bool { + for _, t := range []string{"read_file", "write_file", "edit_file", "append_file", "list_dir"} { + if strings.Contains(name, t) { + return true + } + } + + return false +} + +// formatCompactEntry formats a finished tool log entry as a fixed single line. + +// The result marker (✓/✗) is always shown at the end regardless of truncation. + +// File tools omit duration (always near-instant); paths truncate from the + +// start so the filename is always visible. + +func formatCompactEntry(entry toolLogEntry) string { + result := entry.Result + + if result == "" { + result = "\u23F3" // ⏳ + } + + // File tools: strip duration, keep only marker (✓/✗/⏳) + + isFile := isFileToolEntry(entry.Name) + + if isFile { + if r := []rune(result); len(r) > 0 { + result = string(r[0:1]) // just the symbol + } + } + + // Budget for ArgsSnip: total - name - " " - " " - result + + nameLen := utf8.RuneCountInString(entry.Name) + + resultLen := utf8.RuneCountInString(result) + + argsBudget := maxEntryLineWidth - nameLen - 1 - 1 - resultLen + + args := entry.ArgsSnip + + if args != "" && argsBudget > 3 { + argsRunes := []rune(args) + + if len(argsRunes) > argsBudget { + // Paths: truncate from the start, keeping the filename visible + + if strings.Contains(args, "/") { + args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:]) + } else { + args = string(argsRunes[:argsBudget-1]) + "\u2026" + } + } + + var sb strings.Builder + + sb.Grow(len(entry.Name) + 1 + len(args) + 1 + len(result)) + + sb.WriteString(entry.Name) + + sb.WriteByte(' ') + + sb.WriteString(args) + + sb.WriteByte(' ') + + sb.WriteString(result) + + return sb.String() + } + + // No room for args or args empty + + var sb strings.Builder + + sb.Grow(len(entry.Name) + 1 + len(result)) + + sb.WriteString(entry.Name) + + sb.WriteByte(' ') + + sb.WriteString(result) + + return sb.String() +} + +// formatLatestEntry formats the latest entry command without its result marker. + +// Since the result goes on the next line, the full width is available for the command. + +func formatLatestEntry(entry toolLogEntry) string { + nameLen := utf8.RuneCountInString(entry.Name) + + argsBudget := maxEntryLineWidth - nameLen - 1 // name + space + args (no result) + + args := entry.ArgsSnip + + if args != "" && argsBudget > 3 { + argsRunes := []rune(args) + + if len(argsRunes) > argsBudget { + if strings.Contains(args, "/") { + args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:]) + } else { + args = string(argsRunes[:argsBudget-1]) + "\u2026" + } + } + + var sb strings.Builder + + sb.Grow(len(entry.Name) + 1 + len(args)) + + sb.WriteString(entry.Name) + + sb.WriteByte(' ') + + sb.WriteString(args) + + return sb.String() + } + + return entry.Name +} + +// compressRepeats reduces runs of 3+ identical non-alphanumeric, non-space + +// characters to just 2. e.g. "======" → "==", "---" → "--". + +func compressRepeats(s string) string { + runes := []rune(s) + + if len(runes) < 3 { + return s + } + + var sb strings.Builder + + sb.Grow(len(s)) + + i := 0 + + for i < len(runes) { + r := runes[i] + + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsSpace(r) { + j := i + 1 + + for j < len(runes) && runes[j] == r { + j++ + } + + if j-i >= 3 { + sb.WriteRune(r) + + sb.WriteRune(r) + + i = j + + continue + } + } + + sb.WriteRune(r) + + i++ + } + + return sb.String() +} + +// Display layout constants. + +const ( + displayPastEntries = 4 // number of compact 1-line past entries + + displayErrorLines = 5 // content lines inside the error code block + + statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n" + + streamingDisplayLines = 17 // line count matching buildRichStatus output + +) + +// buildRichStatus builds a fixed-height terminal-like status display. + +// + +// Layout (always the same number of lines): + +// + +// 🔄 Task in progress (N/M) header + +// 📁 workspace-path header + +// ━━━━━━━━━━ separator + +// [N] compact-past-1 ✓ Xs past (1 line each) + +// [N] compact-past-2 ✗ Xs past + +// [N] compact-past-3 ✓ Xs past + +// [N] compact-past-4 ✓ Xs past + +// [N] latest-command latest (no result, wider args) + +// ⏳ latest result + +// reserved + +// ``` error fence + +// err-line / placeholder error body (5 lines) + +// ``` error fence + +// ↩️ Reply to intervene footer (background only) + +func buildRichStatus(task *activeTask, isBackground bool, workspace string) string { + task.mu.Lock() + + defer task.mu.Unlock() + + var sb strings.Builder + + // --- Header --- + + sb.WriteString("\U0001F504 Task in progress (") + + sb.WriteString(strconv.Itoa(task.Iteration)) + + sb.WriteByte('/') + + sb.WriteString(strconv.Itoa(task.MaxIter)) + + sb.WriteString(")\n") + + // Project directory: exec cd (authoritative) → file LCP → workspace basename + + sb.WriteString("\U0001F4C1 ") + + if dir := displayProjectDir(task); dir != "" { + sb.WriteString(dir) + } else if workspace != "" { + project := strings.TrimRight(workspace, "/\\") + + if idx := strings.LastIndex(project, "/"); idx >= 0 { + project = project[idx+1:] + } else if idx := strings.LastIndex(project, "\\"); idx >= 0 { + project = project[idx+1:] + } + + sb.WriteString(project) + } + + sb.WriteByte('\n') + + sb.WriteString(statusSeparator) + + // --- Task entries (displayPastEntries + 2 lines for latest) --- + + entries := task.toolLog + + if len(entries) > maxToolLogEntries { + entries = entries[len(entries)-maxToolLogEntries:] + } + + var pastEntries []toolLogEntry + + var latest *toolLogEntry + + if len(entries) > 0 { + latest = &entries[len(entries)-1] + + if len(entries) > 1 { + start := len(entries) - 1 - displayPastEntries + + if start < 0 { + start = 0 + } + + pastEntries = entries[start : len(entries)-1] + } + } + + // Past entries: exactly displayPastEntries lines (pad if fewer) + + for i := 0; i < displayPastEntries; i++ { + if i < len(pastEntries) { + sb.WriteString(formatCompactEntry(pastEntries[i])) + } else { + sb.WriteString("\u2800") + } + + sb.WriteByte('\n') + } + + // Latest entry: command on one line, result on next + + if latest != nil { + sb.WriteString(formatLatestEntry(*latest)) + + sb.WriteByte('\n') + + sb.WriteString(" ") + + if latest.Result != "" { + sb.WriteString(latest.Result) + } else { + sb.WriteString("\u23F3") + } + + sb.WriteByte('\n') + } else { + sb.WriteString("\u23F3 waiting...\n") + + sb.WriteString("\u2800\n") + } + + // Reserved (1 line) + + sb.WriteString("\u2800\n") + + // --- Error region (code fence, no separator) --- + + sb.WriteString("```\n") + + errEntry := task.lastError + + if errEntry != nil { + sb.WriteString("\u274C ") + + sb.WriteString(formatCompactEntry(*errEntry)) + + sb.WriteByte('\n') + + var detailLines []string + + if errEntry.ErrDetail != "" { + detailLines = strings.Split(errEntry.ErrDetail, "\n") + } + + for i := 0; i < displayErrorLines-1; i++ { + if i < len(detailLines) { + line := compressRepeats(detailLines[i]) + + if runes := []rune(line); len(runes) > maxEntryLineWidth { + line = string(runes[:maxEntryLineWidth-1]) + "\u2026" + } + + sb.WriteString(line) + } else { + sb.WriteString("\u2800") + } + + sb.WriteByte('\n') + } + } else { + sb.WriteString("\u2714 No errors\n") + + for i := 0; i < displayErrorLines-1; i++ { + sb.WriteString("\u2800\n") + } + } + + sb.WriteString("```\n") + + if isBackground { + sb.WriteString("\u21A9\uFE0F Reply to intervene") + } + + return sb.String() +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index ee7f45e81..e2781e935 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -7,490 +7,290 @@ import ( "path/filepath" "slices" "strings" - "sync" "testing" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/providers" - "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" + "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/tools" ) type fakeChannel struct{ id string } -func (f *fakeChannel) Name() string { return "fake" } - -func (f *fakeChannel) Start(ctx context.Context) error { return nil } - -func (f *fakeChannel) Stop(ctx context.Context) error { return nil } - +func (f *fakeChannel) Name() string { return "fake" } +func (f *fakeChannel) Start(ctx context.Context) error { return nil } +func (f *fakeChannel) Stop(ctx context.Context) error { return nil } func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } +func (f *fakeChannel) IsRunning() bool { return true } +func (f *fakeChannel) IsAllowed(string) bool { return true } +func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } +func (f *fakeChannel) ReasoningChannelID() string { return f.id } -func (f *fakeChannel) IsRunning() bool { return true } - -func (f *fakeChannel) IsAllowed(string) bool { return true } - -func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true } - -func (f *fakeChannel) ReasoningChannelID() string { return f.id } - -func TestRecordLastChannel(t *testing.T) { - // Create temp workspace - +func newTestAgentLoop( + t *testing.T, +) (al *AgentLoop, cfg *config.Config, msgBus *bus.MessageBus, provider *mockProvider, cleanup func()) { + t.Helper() tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - - defer os.RemoveAll(tmpDir) - - // Create test config - - cfg := &config.Config{ + cfg = &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, } + msgBus = bus.NewMessageBus() + provider = &mockProvider{} + al = NewAgentLoop(cfg, msgBus, provider) + return al, cfg, msgBus, provider, func() { os.RemoveAll(tmpDir) } +} - // Create agent loop - - msgBus := bus.NewMessageBus() - - provider := &mockProvider{} - - al := NewAgentLoop(cfg, msgBus, provider) - - // Test RecordLastChannel +func TestRecordLastChannel(t *testing.T) { + al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) + defer cleanup() testChannel := "test-channel" - - err = al.RecordLastChannel(testChannel) - if err != nil { + if err := al.RecordLastChannel(testChannel); err != nil { t.Fatalf("RecordLastChannel failed: %v", err) } - - // Verify channel was saved - - lastChannel := al.state.GetLastChannel() - - if lastChannel != testChannel { - t.Errorf("Expected channel '%s', got '%s'", testChannel, lastChannel) + if got := al.state.GetLastChannel(); got != testChannel { + t.Errorf("Expected channel '%s', got '%s'", testChannel, got) } - - // Verify persistence by creating a new agent loop - al2 := NewAgentLoop(cfg, msgBus, provider) - - if al2.state.GetLastChannel() != testChannel { - t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, al2.state.GetLastChannel()) + if got := al2.state.GetLastChannel(); got != testChannel { + t.Errorf("Expected persistent channel '%s', got '%s'", testChannel, got) } } func TestRecordLastChatID(t *testing.T) { - // Create temp workspace - - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - // Create test config - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - // Create agent loop - - msgBus := bus.NewMessageBus() - - provider := &mockProvider{} - - al := NewAgentLoop(cfg, msgBus, provider) - - // Test RecordLastChatID + al, cfg, msgBus, provider, cleanup := newTestAgentLoop(t) + defer cleanup() testChatID := "test-chat-id-123" - - err = al.RecordLastChatID(testChatID) - if err != nil { + if err := al.RecordLastChatID(testChatID); err != nil { t.Fatalf("RecordLastChatID failed: %v", err) } - - // Verify chat ID was saved - - lastChatID := al.state.GetLastChatID() - - if lastChatID != testChatID { - t.Errorf("Expected chat ID '%s', got '%s'", testChatID, lastChatID) + if got := al.state.GetLastChatID(); got != testChatID { + t.Errorf("Expected chat ID '%s', got '%s'", testChatID, got) } - - // Verify persistence by creating a new agent loop - al2 := NewAgentLoop(cfg, msgBus, provider) - - if al2.state.GetLastChatID() != testChatID { - t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, al2.state.GetLastChatID()) - } -} - -func TestRecordLastHeartbeatTarget(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - - provider := &mockProvider{} - - al := NewAgentLoop(cfg, msgBus, provider) - - target := "telegram:-100123/42" - - if err := al.RecordLastHeartbeatTarget(target); err != nil { - t.Fatalf("RecordLastHeartbeatTarget failed: %v", err) - } - - if got := al.state.GetLastHeartbeatTarget(); got != target { - t.Fatalf("GetLastHeartbeatTarget = %q, want %q", got, target) + if got := al2.state.GetLastChatID(); got != testChatID { + t.Errorf("Expected persistent chat ID '%s', got '%s'", testChatID, got) } } func TestNewAgentLoop_StateInitialized(t *testing.T) { // Create temp workspace - tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) // Create test config - cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, } // Create agent loop - msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) // Verify state manager is initialized - if al.state == nil { t.Error("Expected state manager to be initialized") } // Verify state directory was created - stateDir := filepath.Join(tmpDir, "state") - if _, err := os.Stat(stateDir); os.IsNotExist(err) { t.Error("Expected state directory to exist") } } // TestToolRegistry_ToolRegistration verifies tools can be registered and retrieved - func TestToolRegistry_ToolRegistration(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) // Register a custom tool - customTool := &mockCustomTool{} - al.RegisterTool(customTool) // Verify tool is registered by checking it doesn't panic on GetStartupInfo - // (actual tool retrieval is tested in tools package tests) - info := al.GetStartupInfo() - toolsInfo := info["tools"].(map[string]any) - toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list - found := slices.Contains(toolsList, "mock_custom") - if !found { t.Error("Expected custom tool to be registered") } } -// TestToolContext_Updates verifies tool context is updated with channel/chatID - +// TestToolContext_Updates verifies tool context helpers work correctly func TestToolContext_Updates(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) + ctx := tools.WithToolContext(context.Background(), "telegram", "chat-42") + + if got := tools.ToolChannel(ctx); got != "telegram" { + t.Errorf("expected channel 'telegram', got %q", got) + } + if got := tools.ToolChatID(ctx); got != "chat-42" { + t.Errorf("expected chatID 'chat-42', got %q", got) } - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, + // Empty context returns empty strings + if got := tools.ToolChannel(context.Background()); got != "" { + t.Errorf("expected empty channel from bare context, got %q", got) } - - msgBus := bus.NewMessageBus() - - provider := &simpleMockProvider{response: "OK"} - - _ = NewAgentLoop(cfg, msgBus, provider) - - // Verify that ContextualTool interface is defined and can be implemented - - // This test validates the interface contract exists - - ctxTool := &mockContextualTool{} - - // Verify the tool implements the interface correctly - - var _ tools.ContextualTool = ctxTool } // TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved - func TestToolRegistry_GetDefinitions(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) // Register a test tool and verify it shows up in startup info - testTool := &mockCustomTool{} - al.RegisterTool(testTool) info := al.GetStartupInfo() - toolsInfo := info["tools"].(map[string]any) - toolsList := toolsInfo["names"].([]string) // Check that our custom tool name is in the list - found := slices.Contains(toolsList, "mock_custom") - if !found { t.Error("Expected custom tool to be registered") } } // TestAgentLoop_GetStartupInfo verifies startup info contains tools - func TestAgentLoop_GetStartupInfo(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Workspace = tmpDir + cfg.Agents.Defaults.Model = "test-model" + cfg.Agents.Defaults.MaxTokens = 4096 + cfg.Agents.Defaults.MaxToolIterations = 10 msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) info := al.GetStartupInfo() // Verify tools info exists - toolsInfo, ok := info["tools"] - if !ok { t.Fatal("Expected 'tools' key in startup info") } toolsMap, ok := toolsInfo.(map[string]any) - if !ok { t.Fatal("Expected 'tools' to be a map") } count, ok := toolsMap["count"] - if !ok { t.Fatal("Expected 'count' in tools info") } // Should have default tools registered - if count.(int) == 0 { t.Error("Expected at least some tools to be registered") } } // TestAgentLoop_Stop verifies Stop() sets running to false - func TestAgentLoop_Stop(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() - provider := &mockProvider{} - al := NewAgentLoop(cfg, msgBus, provider) // Note: running is only set to true when Run() is called - // We can't test that without starting the event loop - // Instead, verify the Stop method can be called safely - al.Stop() // Verify running is false (initial state or after Stop) - if al.running.Load() { t.Error("Expected agent to be stopped (or never started)") } @@ -504,18 +304,13 @@ type simpleMockProvider struct { func (m *simpleMockProvider) Chat( ctx context.Context, - messages []providers.Message, - tools []providers.ToolDefinition, - model string, - opts map[string]any, ) (*providers.LLMResponse, error) { return &providers.LLMResponse{ - Content: m.response, - + Content: m.response, ToolCalls: []providers.ToolCall{}, }, nil } @@ -524,8 +319,30 @@ func (m *simpleMockProvider) GetDefaultModel() string { return "mock-model" } -// mockCustomTool is a simple mock tool for registration testing +type countingMockProvider struct { + response string + calls int +} +func (m *countingMockProvider) Chat( + ctx context.Context, + messages []providers.Message, + tools []providers.ToolDefinition, + model string, + opts map[string]any, +) (*providers.LLMResponse, error) { + m.calls++ + return &providers.LLMResponse{ + Content: m.response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (m *countingMockProvider) GetDefaultModel() string { + return "counting-mock-model" +} + +// mockCustomTool is a simple mock tool for registration testing type mockCustomTool struct{} func (m *mockCustomTool) Name() string { @@ -538,8 +355,7 @@ func (m *mockCustomTool) Description() string { func (m *mockCustomTool) Parameters() map[string]any { return map[string]any{ - "type": "object", - + "type": "object", "properties": map[string]any{}, } } @@ -548,209 +364,322 @@ func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tool return tools.SilentResult("Custom tool executed") } -// mockContextualTool tracks context updates - -type mockContextualTool struct { - lastChannel string - - lastChatID string -} - -func (m *mockContextualTool) Name() string { - return "mock_contextual" -} - -func (m *mockContextualTool) Description() string { - return "Mock contextual tool" -} - -func (m *mockContextualTool) Parameters() map[string]any { - return map[string]any{ - "type": "object", - - "properties": map[string]any{}, - } -} - -func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult { - return tools.SilentResult("Contextual tool executed") -} - -func (m *mockContextualTool) SetContext(channel, chatID string) { - m.lastChannel = channel - - m.lastChatID = chatID -} - // testHelper executes a message and returns the response - type testHelper struct { al *AgentLoop } func (h testHelper) executeAndGetResponse(tb testing.TB, ctx context.Context, msg bus.InboundMessage) string { // Use a short timeout to avoid hanging - timeoutCtx, cancel := context.WithTimeout(ctx, responseTimeout) - defer cancel() response, err := h.al.processMessage(timeoutCtx, msg) if err != nil { tb.Fatalf("processMessage failed: %v", err) } - return response } const responseTimeout = 3 * time.Second -// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound - -func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { +func TestProcessMessage_UsesRouteSessionKey(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() - - provider := &simpleMockProvider{response: "File operation complete"} - + provider := &simpleMockProvider{response: "ok"} al := NewAgentLoop(cfg, msgBus, provider) + msg := bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "hello", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + } + + route := al.registry.ResolveRoute(routing.RouteInput{ + Channel: msg.Channel, + Peer: extractPeer(msg), + }) + sessionKey := route.SessionKey + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("No default agent found") + } + + helper := testHelper{al: al} + _ = helper.executeAndGetResponse(t, context.Background(), msg) + + history := defaultAgent.Sessions.GetHistory(sessionKey) + if len(history) != 2 { + t.Fatalf("expected session history len=2, got %d", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Fatalf("unexpected first message in session: %+v", history[0]) + } +} + +func TestProcessMessage_CommandOutcomes(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Session: config.SessionConfig{ + DMScope: "per-channel-peer", + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + baseMsg := bus.InboundMessage{ + Channel: "whatsapp", + SenderID: "user1", + ChatID: "chat1", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: baseMsg.Channel, + SenderID: baseMsg.SenderID, + ChatID: baseMsg.ChatID, + Content: "/show channel", + Peer: baseMsg.Peer, + }) + if showResp != "Current channel: whatsapp" { + t.Fatalf("unexpected /show reply: %q", showResp) + } + if provider.calls != 0 { + t.Fatalf("LLM should not be called for handled command, calls=%d", provider.calls) + } + + fooResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: baseMsg.Channel, + SenderID: baseMsg.SenderID, + ChatID: baseMsg.ChatID, + Content: "/foo", + Peer: baseMsg.Peer, + }) + if fooResp != "LLM reply" { + t.Fatalf("unexpected /foo reply: %q", fooResp) + } + if provider.calls != 1 { + t.Fatalf("LLM should be called exactly once after /foo passthrough, calls=%d", provider.calls) + } + + newResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: baseMsg.Channel, + SenderID: baseMsg.SenderID, + ChatID: baseMsg.ChatID, + Content: "/new", + Peer: baseMsg.Peer, + }) + if newResp != "LLM reply" { + t.Fatalf("unexpected /new reply: %q", newResp) + } + if provider.calls != 2 { + t.Fatalf("LLM should be called for passthrough /new command, calls=%d", provider.calls) + } +} + +func TestProcessMessage_SwitchModelShowModelConsistency(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Provider: "openai", + Model: "before-switch", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &countingMockProvider{response: "LLM reply"} + al := NewAgentLoop(cfg, msgBus, provider) + helper := testHelper{al: al} + + switchResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/switch model to after-switch", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if !strings.Contains(switchResp, "Switched model from before-switch to after-switch") { + t.Fatalf("unexpected /switch reply: %q", switchResp) + } + + showResp := helper.executeAndGetResponse(t, context.Background(), bus.InboundMessage{ + Channel: "telegram", + SenderID: "user1", + ChatID: "chat1", + Content: "/show model", + Peer: bus.Peer{ + Kind: "direct", + ID: "user1", + }, + }) + if !strings.Contains(showResp, "Current model: after-switch") { + t.Fatalf("unexpected /show model reply after switch: %q", showResp) + } + + if provider.calls != 0 { + t.Fatalf("LLM should not be called for /switch and /show, calls=%d", provider.calls) + } +} + +// TestToolResult_SilentToolDoesNotSendUserMessage verifies silent tools don't trigger outbound +func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } + + msgBus := bus.NewMessageBus() + provider := &simpleMockProvider{response: "File operation complete"} + al := NewAgentLoop(cfg, msgBus, provider) helper := testHelper{al: al} // ReadFileTool returns SilentResult, which should not send user message - ctx := context.Background() - msg := bus.InboundMessage{ - Channel: "test", - - SenderID: "user1", - - ChatID: "chat1", - - Content: "read test.txt", - + Channel: "test", + SenderID: "user1", + ChatID: "chat1", + Content: "read test.txt", SessionKey: "test-session", } response := helper.executeAndGetResponse(t, ctx, msg) // Silent tool should return the LLM's response directly - if response != "File operation complete" { t.Errorf("Expected 'File operation complete', got: %s", response) } } // TestToolResult_UserFacingToolDoesSendMessage verifies user-facing tools trigger outbound - func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, } msgBus := bus.NewMessageBus() - provider := &simpleMockProvider{response: "Command output: hello world"} - al := NewAgentLoop(cfg, msgBus, provider) - helper := testHelper{al: al} // ExecTool returns UserResult, which should send user message - ctx := context.Background() - msg := bus.InboundMessage{ - Channel: "test", - - SenderID: "user1", - - ChatID: "chat1", - - Content: "run hello", - + Channel: "test", + SenderID: "user1", + ChatID: "chat1", + Content: "run hello", SessionKey: "test-session", } response := helper.executeAndGetResponse(t, ctx, msg) // User-facing tool should include the output in final response - if response != "Command output: hello world" { t.Errorf("Expected 'Command output: hello world', got: %s", response) } } // failFirstMockProvider fails on the first N calls with a specific error - type failFirstMockProvider struct { - failures int - + failures int currentCall int - - failError error - + failError error successResp string } func (m *failFirstMockProvider) Chat( ctx context.Context, - messages []providers.Message, - tools []providers.ToolDefinition, - model string, - opts map[string]any, ) (*providers.LLMResponse, error) { m.currentCall++ - if m.currentCall <= m.failures { return nil, m.failError } - return &providers.LLMResponse{ - Content: m.successResp, - + Content: m.successResp, ToolCalls: []providers.ToolCall{}, }, nil } @@ -760,24 +689,19 @@ func (m *failFirstMockProvider) GetDefaultModel() string { } // TestAgentLoop_ContextExhaustionRetry verify that the agent retries on context errors - func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, @@ -786,61 +710,39 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { msgBus := bus.NewMessageBus() // Create a provider that fails once with a context error - contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens") - provider := &failFirstMockProvider{ - failures: 1, - - failError: contextErr, - + failures: 1, + failError: contextErr, successResp: "Recovered from context error", } al := NewAgentLoop(cfg, msgBus, provider) // Inject some history to simulate a full context - sessionKey := "test-session-context" - // Create dummy history - history := []providers.Message{ {Role: "system", Content: "System prompt"}, - {Role: "user", Content: "Old message 1"}, - {Role: "assistant", Content: "Old response 1"}, - {Role: "user", Content: "Old message 2"}, - {Role: "assistant", Content: "Old response 2"}, - {Role: "user", Content: "Trigger message"}, } - defaultAgent := al.registry.GetDefaultAgent() - if defaultAgent == nil { t.Fatal("No default agent found") } - defaultAgent.Sessions.SetHistory(sessionKey, history) // Call ProcessDirectWithChannel - // Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration - response, err := al.ProcessDirectWithChannel( - context.Background(), - "Trigger message", - sessionKey, - "test", - "test-chat", ) if err != nil { @@ -852,63 +754,69 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { } // We expect 2 calls: 1st failed, 2nd succeeded - if provider.currentCall != 2 { t.Errorf("Expected 2 calls (1 fail + 1 success), got %d", provider.currentCall) } // Check final history length - finalHistory := defaultAgent.Sessions.GetHistory(sessionKey) - // We verify that the history has been modified (compressed) - // Original length: 6 - // Expected behavior: compression drops ~50% of history (mid slice) - // We can assert that the length is NOT what it would be without compression. - // Without compression: 6 + 1 (new user msg) + 1 (assistant msg) = 8 - if len(finalHistory) >= 8 { t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) } } -func TestShouldInjectReminder(t *testing.T) { - tests := []struct { - name string +func TestProcessDirectWithChannel_InitializesMCPInAgentMode(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) - iteration int - - interval int - - want bool - }{ - {"first iteration skipped", 1, 5, false}, - - {"iteration 5 interval 5", 5, 5, true}, - - {"iteration 10 interval 5", 10, 5, true}, - - {"iteration 3 interval 5", 3, 5, false}, - - {"interval zero disabled", 5, 0, false}, - - {"interval negative disabled", 5, -1, false}, - - {"iteration 2 interval 1", 2, 1, true}, + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + Tools: config.ToolsConfig{ + MCP: config.MCPConfig{ + ToolConfig: config.ToolConfig{ + Enabled: true, + }, + }, + }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := shouldInjectReminder(tt.iteration, tt.interval) + msgBus := bus.NewMessageBus() + provider := &mockProvider{} + al := NewAgentLoop(cfg, msgBus, provider) + defer al.Close() - if got != tt.want { - t.Errorf("shouldInjectReminder(%d, %d) = %v, want %v", tt.iteration, tt.interval, got, tt.want) - } - }) + if al.mcp.hasManager() { + t.Fatal("expected MCP manager to be nil before first direct processing") + } + + _, err = al.ProcessDirectWithChannel( + context.Background(), + "hello", + "session-1", + "cli", + "direct", + ) + if err != nil { + t.Fatalf("ProcessDirectWithChannel failed: %v", err) + } + + if !al.mcp.hasManager() { + t.Fatal("expected MCP manager to be initialized in direct agent mode") } } @@ -917,96 +825,63 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) { if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - defer os.RemoveAll(tmpDir) cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, } al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{}) - chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil) if err != nil { t.Fatalf("Failed to create channel manager: %v", err) } - for name, id := range map[string]string{ - "whatsapp": "rid-whatsapp", - - "telegram": "rid-telegram", - - "feishu": "rid-feishu", - - "discord": "rid-discord", - - "maixcam": "rid-maixcam", - - "qq": "rid-qq", - - "dingtalk": "rid-dingtalk", - - "slack": "rid-slack", - - "line": "rid-line", - - "onebot": "rid-onebot", - - "wecom": "rid-wecom", - + "whatsapp": "rid-whatsapp", + "telegram": "rid-telegram", + "feishu": "rid-feishu", + "discord": "rid-discord", + "maixcam": "rid-maixcam", + "qq": "rid-qq", + "dingtalk": "rid-dingtalk", + "slack": "rid-slack", + "line": "rid-line", + "onebot": "rid-onebot", + "wecom": "rid-wecom", "wecom_app": "rid-wecom-app", } { chManager.RegisterChannel(name, &fakeChannel{id: id}) } - al.SetChannelManager(chManager) - tests := []struct { channel string - - wantID string + wantID string }{ {channel: "whatsapp", wantID: "rid-whatsapp"}, - {channel: "telegram", wantID: "rid-telegram"}, - {channel: "feishu", wantID: "rid-feishu"}, - {channel: "discord", wantID: "rid-discord"}, - {channel: "maixcam", wantID: "rid-maixcam"}, - {channel: "qq", wantID: "rid-qq"}, - {channel: "dingtalk", wantID: "rid-dingtalk"}, - {channel: "slack", wantID: "rid-slack"}, - {channel: "line", wantID: "rid-line"}, - {channel: "onebot", wantID: "rid-onebot"}, - {channel: "wecom", wantID: "rid-wecom"}, - {channel: "wecom_app", wantID: "rid-wecom-app"}, - {channel: "unknown", wantID: ""}, } for _, tt := range tests { t.Run(tt.channel, func(t *testing.T) { got := al.targetReasoningChannelID(tt.channel) - if got != tt.wantID { t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID) } @@ -1014,3097 +889,34 @@ func TestTargetReasoningChannelID_AllChannels(t *testing.T) { } } -func TestBuildTaskReminder_WithoutBlocker(t *testing.T) { - msg := buildTaskReminder("implement feature X", "") - - if msg.Role != "user" { - t.Errorf("expected role 'user', got %q", msg.Role) - } - - if !strings.Contains(msg.Content, "[TASK REMINDER]") { - t.Error("expected content to contain '[TASK REMINDER]'") - } - - if !strings.Contains(msg.Content, "implement feature X") { - t.Error("expected content to contain original message") - } - - if strings.Contains(msg.Content, "blocker") { - t.Error("expected content NOT to contain 'blocker' when no blocker provided") - } - - if !strings.Contains(msg.Content, "move on") { - t.Error("expected content to contain completion prompt") - } -} - -func TestBuildTaskReminder_WithBlocker(t *testing.T) { - msg := buildTaskReminder("implement feature X", "ModuleNotFoundError: No module named 'foo'") - - if msg.Role != "user" { - t.Errorf("expected role 'user', got %q", msg.Role) - } - - if !strings.Contains(msg.Content, "[TASK REMINDER]") { - t.Error("expected content to contain '[TASK REMINDER]'") - } - - if !strings.Contains(msg.Content, "implement feature X") { - t.Error("expected content to contain original message") - } - - if !strings.Contains(msg.Content, "Last blocker") { - t.Error("expected content to contain 'Last blocker'") - } - - if !strings.Contains(msg.Content, "ModuleNotFoundError") { - t.Error("expected content to contain blocker text") - } -} - -func TestResolveProvider_CachesProviders(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - Provider: "vllm", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - - Providers: config.ProvidersConfig{ - VLLM: config.ProviderConfig{ - APIKey: "test-key", - - APIBase: "https://example.com/v1", - }, - }, - } - - msgBus := bus.NewMessageBus() - - primary := &mockProvider{} - - al := NewAgentLoop(cfg, msgBus, primary) - - // First call creates and caches a provider for "vllm/test-model" - - p1 := al.resolveProvider("vllm", "test-model", primary) - - if p1 == primary { - t.Fatal("expected a new provider from legacy providers config, not the fallback") - } - - // Calling again should return the same instance (cached) - - p2 := al.resolveProvider("vllm", "test-model", primary) - - if p1 != p2 { - t.Fatal("expected same cached instance on second call") - } -} - -func TestResolveProvider_FallsBackOnError(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - Provider: "vllm", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - - primary := &mockProvider{} - - al := NewAgentLoop(cfg, msgBus, primary) - - // Request a provider that can't be created (no config for "nonexistent") - - p := al.resolveProvider("nonexistent", "unknown-model", primary) - - if p != primary { - t.Fatal("expected fallback to primary provider on creation error") - } - - // Ensure the failed provider is NOT cached - - if _, ok := al.providerCache["nonexistent"]; ok { - t.Fatal("failed provider should not be cached") - } -} - -func TestResolveProvider_EmptyNameReturnsFallback(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - - primary := &mockProvider{} - - al := NewAgentLoop(cfg, msgBus, primary) - - p := al.resolveProvider("", "", primary) - - if p != primary { - t.Fatal("expected fallback provider for empty name") - } -} - -// TestSlashCommandResponseSkipsPlaceholder verifies that slash command responses - -// are published with SkipPlaceholder=true so they don't overwrite the ongoing task - -// status bubble. - -func TestSlashCommandResponseSkipsPlaceholder(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - - provider := &mockProvider{} - - al := NewAgentLoop(cfg, msgBus, provider) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - - defer cancel() - - go func() { - _ = al.Run(ctx) - }() - - // Send a slash command - - msgBus.PublishInbound(context.Background(), bus.InboundMessage{ - Channel: "telegram", - - SenderID: "user1", - - ChatID: "chat1", - - Content: "/skills", - }) - - // Read the outbound message - - outMsg, ok := msgBus.SubscribeOutbound(ctx) - - if !ok { - t.Fatal("expected outbound message from slash command") - } - - if !outMsg.SkipPlaceholder { - t.Errorf("expected SkipPlaceholder=true for slash command response, got false") - } -} - -func TestBuildTaskReminder_Truncation(t *testing.T) { - // Build a long message (1000 runes) - - longMsg := strings.Repeat("あ", 1000) - - longBlocker := strings.Repeat("X", 500) - - msg := buildTaskReminder(longMsg, longBlocker) - - // The full message should NOT contain 1000 'あ' characters - - runeCount := strings.Count(msg.Content, "あ") - - if runeCount >= 1000 { - t.Errorf("expected task message to be truncated, got %d 'あ' runes", runeCount) - } - - // Should be at most taskReminderMaxChars (500) runes for the task part - - if runeCount > taskReminderMaxChars { - t.Errorf("expected at most %d task runes, got %d", taskReminderMaxChars, runeCount) - } - - // Blocker should be truncated too - - xCount := strings.Count(msg.Content, "X") - - if xCount >= 500 { - t.Errorf("expected blocker to be truncated, got %d 'X' chars", xCount) - } - - if xCount > blockerMaxChars { - t.Errorf("expected at most %d blocker chars, got %d", blockerMaxChars, xCount) - } -} - -func TestBuildPlanReminder(t *testing.T) { - tests := []struct { - name string - - status string - - wantOK bool - - wantSubstr string - }{ - {"interviewing", "interviewing", true, "interviewing the user"}, - - {"review", "review", true, "under review"}, - - {"executing returns false", "executing", false, ""}, - - {"empty returns false", "", false, ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - msg, ok := buildPlanReminder(tt.status) - - if ok != tt.wantOK { - t.Fatalf("buildPlanReminder(%q) ok = %v, want %v", tt.status, ok, tt.wantOK) - } - - if !ok { - return - } - - if msg.Role != "user" { - t.Errorf("expected role 'user', got %q", msg.Role) - } - - if !strings.Contains(msg.Content, tt.wantSubstr) { - t.Errorf("expected content to contain %q, got %q", tt.wantSubstr, msg.Content) - } - }) - } -} - -// ---------- /plan command tests ---------- - -func newTestAgentLoop(t *testing.T) (*AgentLoop, func()) { - t.Helper() - - tmpDir, err := os.MkdirTemp("", "agent-plan-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - - provider := &mockProvider{} - - al := NewAgentLoop(cfg, msgBus, provider) - - return al, func() { os.RemoveAll(tmpDir) } -} - -func TestPlanCommand_ShowNoPlan(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"}) - - if !handled { - t.Fatal("expected /plan to be handled") - } - - if !strings.Contains(response, "No active plan") { - t.Errorf("expected 'No active plan', got %q", response) - } -} - -func TestSplitChatAndThread(t *testing.T) { - tests := []struct { - name string - - chatID string - - wantChatID string - - wantThread int - }{ - {name: "plain chat", chatID: "-100123", wantChatID: "-100123", wantThread: 0}, - - {name: "chat with thread", chatID: "-100123/77", wantChatID: "-100123", wantThread: 77}, - - {name: "invalid thread", chatID: "-100123/abc", wantChatID: "-100123", wantThread: 0}, - - {name: "empty", chatID: "", wantChatID: "", wantThread: 0}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotChatID, gotThread := splitChatAndThread(tt.chatID) - - if gotChatID != tt.wantChatID || gotThread != tt.wantThread { - t.Fatalf( - - "splitChatAndThread(%q) = (%q, %d), want (%q, %d)", - - tt.chatID, - - gotChatID, - - gotThread, - - tt.wantChatID, - - tt.wantThread, - ) - } - }) - } -} - -func TestHeartbeatCommandThreadHerePersistsConfig(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - var saved bool - - var updatedThread int - - al.SetConfigSaver(func(cfg *config.Config) error { - saved = true - - if cfg.Channels.Telegram.HeartbeatThreadID != 42 { - t.Fatalf("HeartbeatThreadID in saver = %d, want 42", cfg.Channels.Telegram.HeartbeatThreadID) - } - - return nil - }) - - al.SetHeartbeatThreadUpdater(func(threadID int) { updatedThread = threadID }) - - msg := bus.InboundMessage{ - Content: "/heartbeat thread here", - - Channel: "telegram", - - ChatID: "-100500/42", - } - - resp, handled := al.handleCommand(context.Background(), msg) - - if !handled { - t.Fatal("expected /heartbeat command to be handled") - } - - if !strings.Contains(resp, "Heartbeat thread set to 42") { - t.Fatalf("unexpected response: %q", resp) - } - - if !saved { - t.Fatal("expected config saver to be called") - } - - if updatedThread != 42 { - t.Fatalf("updatedThread = %d, want 42", updatedThread) - } - - if got := al.cfg.Channels.Telegram.HeartbeatThreadID; got != 42 { - t.Fatalf("cfg heartbeat thread = %d, want 42", got) - } - - if got := al.state.GetHeartbeatTarget(); got != "telegram:-100500" { - t.Fatalf("state heartbeat target = %q, want %q", got, "telegram:-100500") - } -} - -func TestHeartbeatCommandThreadOff(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - al.cfg.Channels.Telegram.HeartbeatThreadID = 99 - - resp, handled := al.handleCommand(context.Background(), bus.InboundMessage{ - Content: "/heartbeat thread off", - - Channel: "telegram", - - ChatID: "-100500/42", - }) - - if !handled { - t.Fatal("expected /heartbeat command to be handled") - } - - if !strings.Contains(resp, "disabled") { - t.Fatalf("unexpected response: %q", resp) - } - - if got := al.cfg.Channels.Telegram.HeartbeatThreadID; got != 0 { - t.Fatalf("cfg heartbeat thread = %d, want 0", got) - } -} - -func TestPlanCommand_StartNewPlan(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - // /plan <task> should NOT be handled by handleCommand — it falls through - - // to the LLM queue via expandPlanCommand. - - _, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Set up monitoring"}) - - if handled { - t.Fatal("expected /plan <task> NOT to be handled (should fall through to LLM)") - } - - // expandPlanCommand writes the seed and rewrites the message - - msg := bus.InboundMessage{Content: "/plan Set up monitoring"} - - expanded, compact, ok := al.expandPlanCommand(msg) - - if !ok { - t.Fatal("expected expandPlanCommand to succeed") - } - - if expanded != "Set up monitoring" { - t.Errorf("expected expanded = 'Set up monitoring', got %q", expanded) - } - - if !strings.Contains(compact, "Set up monitoring") { - t.Errorf("expected compact to contain task, got %q", compact) - } - - // Verify plan was created - - agent := al.registry.GetDefaultAgent() - - if !agent.ContextBuilder.HasActivePlan() { - t.Error("expected active plan after expandPlanCommand") - } - - if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" { - t.Errorf("expected 'interviewing', got %q", status) - } -} - -func TestPlanCommand_StartBlockedByExisting(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - // Start first plan via expandPlanCommand - - al.expandPlanCommand(bus.InboundMessage{Content: "/plan First task"}) - - // Try to start another — handleCommand should block it on the fast path - - response, handled := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan Second task"}) - - if !handled { - t.Fatal("expected second /plan to be handled (blocked)") - } - - if !strings.Contains(response, "already active") { - t.Errorf("expected 'already active', got %q", response) - } -} - -func TestPlanCommand_Clear(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - // Start plan then clear - - al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"}) - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"}) - - if !strings.Contains(response, "Plan cleared") { - t.Errorf("expected 'Plan cleared', got %q", response) - } - - agent := al.registry.GetDefaultAgent() - - if agent.ContextBuilder.HasActivePlan() { - t.Error("expected no plan after clear") - } -} - -func TestPlanCommand_ClearNoPlan(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan clear"}) - - if !strings.Contains(response, "No active plan") { - t.Errorf("expected 'No active plan', got %q", response) - } -} - -func TestPlanCommand_Start(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - agent := al.registry.GetDefaultAgent() - - // Create interviewing plan with phases (start requires phases) - - plan := "# Active Plan\n\n> Task: Test task\n> Status: interviewing\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" - - _ = agent.ContextBuilder.WriteMemory(plan) - - // Transition to executing via /plan start - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) - - if !strings.Contains(response, "approved") { - t.Errorf("expected 'approved', got %q", response) - } - - if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" { - t.Errorf("expected 'executing', got %q", status) - } - - // planStartPending must be set so Run() enqueues an LLM trigger - - if !al.planStartPending { - t.Error("expected planStartPending to be true after /plan start") - } -} - -func TestPlanCommand_StartFromReview(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - agent := al.registry.GetDefaultAgent() - - // Create a plan in review status - - plan := "# Active Plan\n\n> Task: Test task\n> Status: review\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" - - _ = agent.ContextBuilder.WriteMemory(plan) - - // Approve via /plan start - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) - - if !strings.Contains(response, "approved") { - t.Errorf("expected 'approved', got %q", response) - } - - if status := agent.ContextBuilder.GetPlanStatus(); status != "executing" { - t.Errorf("expected 'executing', got %q", status) - } - - if !al.planStartPending { - t.Error("expected planStartPending to be true after /plan start from review") - } -} - -func TestPlanCommand_StartNoPhases(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - // Create interviewing plan without phases - - al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"}) - - // Should be blocked because no phases exist - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) - - if !strings.Contains(response, "no phases") { - t.Errorf("expected 'no phases' error, got %q", response) - } - - agent := al.registry.GetDefaultAgent() - - if status := agent.ContextBuilder.GetPlanStatus(); status != "interviewing" { - t.Errorf("expected status to remain 'interviewing', got %q", status) - } - - if al.planStartPending { - t.Error("planStartPending must not be set when start is rejected (no phases)") - } -} - -func TestPlanCommand_StartAlreadyExecuting(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - agent := al.registry.GetDefaultAgent() - - // Create interviewing plan with phases, then start - - plan := "# Active Plan\n\n> Task: Test task\n> Status: interviewing\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" - - _ = agent.ContextBuilder.WriteMemory(plan) - - al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) - - // Clear the flag from the first call (simulating Run() consuming it) - - al.planStartPending = false - - // Try start again — should be rejected - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan start"}) - - if !strings.Contains(response, "already executing") { - t.Errorf("expected 'already executing', got %q", response) - } - - if al.planStartPending { - t.Error("planStartPending must not be set when plan is already executing") - } -} - -func TestPlanCommand_Done(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - agent := al.registry.GetDefaultAgent() - - // Write a plan directly with phases - - plan := `# Active Plan - - - -> Task: Test task - -> Status: executing - -> Phase: 1 - - - -## Phase 1: Setup - -- [ ] Step one - -- [ ] Step two - - - -## Context - -Test context - -` - - agent.ContextBuilder.WriteMemory(plan) - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done 1"}) - - if !strings.Contains(response, "Marked step 1") { - t.Errorf("expected confirmation, got %q", response) - } -} - -func TestPlanCommand_DoneInvalidStep(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - al.expandPlanCommand(bus.InboundMessage{Content: "/plan Test task"}) - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan done abc"}) - - if !strings.Contains(response, "positive integer") { - t.Errorf("expected step validation error, got %q", response) - } -} - -func TestPlanCommand_Add(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - agent := al.registry.GetDefaultAgent() - - plan := `# Active Plan - - - -> Task: Test task - -> Status: executing - -> Phase: 1 - - - -## Phase 1: Setup - -- [ ] Step one - - - -## Context - -Test context - -` - - agent.ContextBuilder.WriteMemory(plan) - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan add New step here"}) - - if !strings.Contains(response, "Added step") { - t.Errorf("expected 'Added step', got %q", response) - } - - content := agent.ContextBuilder.ReadMemory() - - if !strings.Contains(content, "New step here") { - t.Error("expected new step in plan content") - } -} - -func TestPlanCommand_Next(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - agent := al.registry.GetDefaultAgent() - - plan := `# Active Plan - - - -> Task: Test task - -> Status: executing - -> Phase: 1 - - - -## Phase 1: Setup - -- [x] Step one - - - -## Phase 2: Deploy - -- [ ] Step two - - - -## Context - -Test - -` - - agent.ContextBuilder.WriteMemory(plan) - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan next"}) - - if !strings.Contains(response, "phase 2") { - t.Errorf("expected 'phase 2', got %q", response) - } - - if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 2 { - t.Errorf("expected phase 2, got %d", phase) - } -} - -func TestPlanCommand_ShowActivePlan(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - agent := al.registry.GetDefaultAgent() - - plan := `# Active Plan - - - -> Task: Deploy app - -> Status: executing - -> Phase: 1 - - - -## Phase 1: Build - -- [x] Compile code - -- [ ] Run tests - - - -## Context - -Production server - -` - - agent.ContextBuilder.WriteMemory(plan) - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{Content: "/plan"}) - - if !strings.Contains(response, "Deploy app") { - t.Errorf("expected task name in display, got %q", response) - } - - if !strings.Contains(response, "Phase 1") { - t.Errorf("expected phase info in display, got %q", response) - } -} - -// TestAutoPhaseAdvance verifies that auto-advance sends notification after LLM iteration - -// when current phase is complete. - -func TestAutoPhaseAdvance(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-auto-advance-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - - provider := &simpleMockProvider{response: "OK"} - - al := NewAgentLoop(cfg, msgBus, provider) - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - t.Fatal("No default agent") - } - - // Write plan with phase 1 complete - - plan := `# Active Plan - - - -> Task: Test auto advance - -> Status: executing - -> Phase: 1 - - - -## Phase 1: Setup - -- [x] Step one - -- [x] Step two - - - -## Phase 2: Deploy - -- [ ] Step three - - - -## Context - -Test - -` - - agent.ContextBuilder.WriteMemory(plan) - - // Process a message which triggers runAgentLoop - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - - defer cancel() - - _, err = al.ProcessDirectWithChannel(ctx, "continue", "auto-advance-test", "test", "chat1") - if err != nil { - t.Fatalf("ProcessDirectWithChannel failed: %v", err) - } - - // After processing, phase should be auto-advanced - - if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 2 { - t.Errorf("expected phase auto-advanced to 2, got %d", phase) - } -} - -// TestAutoCompleteClears verifies that plan is marked completed with correct phase when all phases are complete. - -func TestAutoCompleteClears(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-auto-complete-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - msgBus := bus.NewMessageBus() - - provider := &simpleMockProvider{response: "All done"} - - al := NewAgentLoop(cfg, msgBus, provider) - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - t.Fatal("No default agent") - } - - // Write fully complete plan - - plan := `# Active Plan - - - -> Task: Test auto complete - -> Status: executing - -> Phase: 1 - - - -## Phase 1: Setup - -- [x] Step one - -- [x] Step two - - - -## Context - -Test - -` - - agent.ContextBuilder.WriteMemory(plan) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - - defer cancel() - - _, err = al.ProcessDirectWithChannel(ctx, "finish up", "auto-complete-test", "test", "chat1") - if err != nil { - t.Fatalf("ProcessDirectWithChannel failed: %v", err) - } - - // Plan should be kept with status "completed" and phase set to total - - if !agent.ContextBuilder.HasActivePlan() { - t.Error("expected plan to be retained after completion") - } - - if status := agent.ContextBuilder.GetPlanStatus(); status != "completed" { - t.Errorf("expected plan status 'completed', got %q", status) - } - - if phase := agent.ContextBuilder.GetCurrentPhase(); phase != 1 { - t.Errorf("expected phase 1 (total phases), got %d", phase) - } -} - -func TestIsToolAllowedDuringInterview_FuzzyNames(t *testing.T) { - tests := []struct { - name string - - args map[string]any - - want bool - }{ - // Exact names — read tools allowed - - {"read_file", nil, true}, - - {"list_dir", nil, true}, - - {"web_search", nil, true}, - - {"web_fetch", nil, true}, - - // Fuzzy variants — should also be allowed - - {"readfile", nil, true}, - - {"ReadFile", nil, true}, - - {"listdir", nil, true}, - - {"websearch", nil, true}, - - {"webfetch", nil, true}, - - // Message tool — allowed (needed for interview questions) - - {"message", nil, true}, - - {"Message", nil, true}, - - // Write to MEMORY.md — allowed - - {"edit_file", map[string]any{"path": "/ws/memory/MEMORY.md"}, true}, - - {"editfile", map[string]any{"path": "/ws/memory/MEMORY.md"}, true}, - - {"EditFile", map[string]any{"path": "/ws/memory/MEMORY.md"}, true}, - - // Write to non-MEMORY.md — blocked - - {"edit_file", map[string]any{"path": "/ws/main.go"}, false}, - - {"editfile", map[string]any{"path": "/ws/main.go"}, false}, - - // exec — read-only commands allowed - - {"exec", map[string]any{"command": "find . -name '*.py'"}, true}, - - {"exec", map[string]any{"command": "ls -la"}, true}, - - {"exec", map[string]any{"command": "grep -r TODO ."}, true}, - - {"exec", map[string]any{"command": "cat README.md"}, true}, - - // exec — cd prefix stripped - - {"exec", map[string]any{"command": "cd /home/user/project && find . -type f"}, true}, - - {"exec", map[string]any{"command": "cd /tmp && rm -rf *"}, false}, - - // exec — write operators blocked - - {"exec", map[string]any{"command": "find . > output.txt"}, false}, - - {"exec", map[string]any{"command": "ls -la >> log.txt"}, false}, - - {"exec", map[string]any{"command": "cat foo | tee bar.txt"}, false}, - - // exec — path traversal blocked - - {"exec", map[string]any{"command": "cat ../../etc/passwd"}, false}, - - {"exec", map[string]any{"command": "find ../../"}, false}, - - {"exec", map[string]any{"command": "ls ../secret"}, false}, - - // exec — absolute paths blocked - - {"exec", map[string]any{"command": "cat /etc/passwd"}, false}, - - {"exec", map[string]any{"command": "find /etc -name '*.conf'"}, false}, - - {"exec", map[string]any{"command": "ls /root"}, false}, - - // exec — write commands blocked - - {"exec", map[string]any{"command": "rm -rf /"}, false}, - - {"exec", map[string]any{"command": "mv a b"}, false}, - - // exec — no args / empty command blocked - - {"exec", nil, false}, - - {"exec", map[string]any{"command": ""}, false}, - - {"Exec", nil, false}, - } - - for _, tt := range tests { - got := isToolAllowedDuringInterview(tt.name, tt.args) - - if got != tt.want { - t.Errorf("isToolAllowedDuringInterview(%q, %v) = %v, want %v", tt.name, tt.args, got, tt.want) - } - } -} - -func TestBuildArgsSnippet_ExecStripsCD(t *testing.T) { - tests := []struct { - name string - - tool string - - args map[string]any - - workspace string - - wantSnip string - }{ - { - name: "exec strips cd prefix", - - tool: "exec", - - args: map[string]any{ - "command": "cd /home/user/workspace/project/my-projects && pytest tests/test_integration.py", - }, - - workspace: "/home/user/workspace", - - wantSnip: "pytest tests/test_integration.py", - }, - - { - name: "exec no cd prefix, flags stripped", - - tool: "exec", - - args: map[string]any{"command": "ls -la"}, - - workspace: "/ws", - - wantSnip: "ls", - }, - - { - name: "exec empty command", - - tool: "exec", - - args: map[string]any{}, - - workspace: "/ws", - - wantSnip: "{}", - }, - - { - name: "read_file strips workspace", - - tool: "read_file", - - args: map[string]any{"path": "/home/user/workspace/src/main.go"}, - - workspace: "/home/user/workspace", - - wantSnip: "src/main.go", - }, - - { - name: "edit_file shows path", - - tool: "edit_file", - - args: map[string]any{"path": "/ws/config.json", "old_text": "old value here"}, - - workspace: "/ws", - - wantSnip: "config.json", - }, - - { - name: "file tool long path prioritizes filename", - - tool: "read_file", - - args: map[string]any{ - "path": "/ws/projects/terra-py-form/src/terra_py_form/hot/state/backend.py", - }, - - workspace: "/ws", - - wantSnip: "projects/terra-py-form/src/terra_py_form/hot/sta\u2026/backend.py", - }, - - { - name: "unknown tool shows raw JSON", - - tool: "web_search", - - args: map[string]any{"query": "hello"}, - - workspace: "/ws", - - wantSnip: `{"query":"hello"}`, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildArgsSnippet(tt.tool, tt.args, tt.workspace) - - if got != tt.wantSnip { - t.Errorf("buildArgsSnippet(%q) = %q, want %q", tt.tool, got, tt.wantSnip) - } - }) - } -} - -func TestFormatCompactEntry(t *testing.T) { - tests := []struct { - name string - - entry toolLogEntry - - wantSub string // must be a substring - - wantMark string // result marker must appear - - noTime bool // if true, duration should NOT appear - }{ - { - name: "exec short entry", - - entry: toolLogEntry{Name: "[1] exec", ArgsSnip: "ls", Result: "✓ 1.0s"}, - - wantSub: "exec ls", - - wantMark: "✓ 1.0s", // exec keeps duration - - }, - - { - name: "exec long entry truncated from end", - - entry: toolLogEntry{ - Name: "[2] exec", - - ArgsSnip: "pytest tests/integration/test_very_long_name.py", - - Result: "✗ 3.0s", - }, - - wantMark: "✗", - }, - - { - name: "file tool omits duration, shows filename", - - entry: toolLogEntry{ - Name: "[3] edit_file", - - ArgsSnip: "projects/terra/src/deep/nested/backend.py", - - Result: "✓ 0.0s", - }, - - wantSub: "backend.py", - - wantMark: "✓", - - noTime: true, - }, - - { - name: "file tool path truncates from start", - - entry: toolLogEntry{ - Name: "[4] read_file", - - ArgsSnip: "projects/terra-py-form/src/terra_py_form/hot/state/backend.py", - - Result: "✓ 0.1s", - }, - - wantSub: "backend.py", - - wantMark: "✓", - - noTime: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := formatCompactEntry(tt.entry) - - if tt.wantSub != "" && !strings.Contains(got, tt.wantSub) { - t.Errorf("expected to contain %q, got: %q", tt.wantSub, got) - } - - if !strings.Contains(got, tt.wantMark) { - t.Errorf("result marker %q missing from: %q", tt.wantMark, got) - } - - if tt.noTime && strings.Contains(got, "0s") { - t.Errorf("file tool should omit duration, got: %q", got) - } - - // Must not exceed maxEntryLineWidth - - if runeLen := len([]rune(got)); runeLen > maxEntryLineWidth { - t.Errorf("entry too wide: %d runes (max %d): %q", runeLen, maxEntryLineWidth, got) - } - }) - } -} - -func TestBuildRichStatus(t *testing.T) { - task := &activeTask{ - Iteration: 3, - - MaxIter: 20, - - toolLog: []toolLogEntry{ - {Name: "exec", ArgsSnip: "ls -la", Result: "✓ 1.2s"}, - - {Name: "exec", ArgsSnip: "pytest tests/", Result: "✓ 5.0s"}, - - {Name: "read_file", ArgsSnip: "src/main.go", Result: "⏳"}, - }, - } - - got := buildRichStatus(task, false, "/home/user/my-projects") - - mustContain := []string{ - "Task in progress (3/20)", - - "my-projects", - - "read_file", // latest entry - - "No errors", // no error yet - - } - - for _, s := range mustContain { - if !strings.Contains(got, s) { - t.Errorf("expected output to contain %q, got:\n%s", s, got) - } - } - - // Non-background: should NOT have reply prompt - - if strings.Contains(got, "Reply to intervene") { - t.Error("non-background task should not have reply prompt") - } - - // Background: should have reply prompt - - bgGot := buildRichStatus(task, true, "/home/user/my-projects") - - if !strings.Contains(bgGot, "Reply to intervene") { - t.Error("background task should have reply prompt") - } -} - -func TestBuildRichStatus_ProjectDir(t *testing.T) { - // exec-based projectDir takes priority - - task := &activeTask{ - Iteration: 1, - - MaxIter: 10, - - projectDir: "terra-py-form", - - toolLog: []toolLogEntry{ - {Name: "exec", ArgsSnip: "ls", Result: "✓ 0.1s"}, - }, - } - - got := buildRichStatus(task, false, "/home/user/.picoclaw/workspace") - - if !strings.Contains(got, "terra-py-form") { - t.Errorf("expected projectDir in output, got:\n%s", got) - } - - // fileCommonDir fallback - - task2 := &activeTask{ - Iteration: 1, - - MaxIter: 10, - - fileCommonDir: "projects/terra-py-form", - - toolLog: []toolLogEntry{ - {Name: "read_file", ArgsSnip: "src/main.py", Result: "✓ 0.1s"}, - }, - } - - got2 := buildRichStatus(task2, false, "/home/user/.picoclaw/workspace") - - if !strings.Contains(got2, "terra-py-form") { - t.Errorf("expected fileCommonDir basename in output, got:\n%s", got2) - } - - // workspace basename fallback with trailing slash - - task3 := &activeTask{ - Iteration: 1, - - MaxIter: 10, - - toolLog: []toolLogEntry{ - {Name: "exec", ArgsSnip: "ls", Result: "✓ 0.1s"}, - }, - } - - for _, ws := range []string{"/home/user/my-project/", "/home/user/my-project"} { - got := buildRichStatus(task3, false, ws) - - if !strings.Contains(got, "my-project") { - t.Errorf("workspace %q: expected 'my-project' in output, got:\n%s", ws, got) - } - } -} - -func TestExtractExecProjectDir(t *testing.T) { - tests := []struct { - name string - - cmd string - - want string - }{ - {"cd deep path", "cd /ws/projects/terra-py-form && pytest", "terra-py-form"}, - - {"cd direct subdir", "cd /ws/my-app && make build", "my-app"}, - - {"cd trailing slash", "cd /ws/my-app/ && ls", "my-app"}, - - {"cd to workspace", "cd /ws && ls", "ws"}, - - {"no cd prefix", "pytest tests/", ""}, - - {"empty command", "", ""}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - args := map[string]any{"command": tt.cmd} - - got := extractExecProjectDir(args) - - if got != tt.want { - t.Errorf("extractExecProjectDir(%q) = %q, want %q", tt.cmd, got, tt.want) - } - }) - } -} - -func TestFileParentRelDir(t *testing.T) { - ws := "/home/user/.picoclaw/workspace" - - tests := []struct { - name string - - path string - - want string - }{ - {"deep path", ws + "/projects/terra/src/main.py", "projects/terra/src"}, - - {"direct subdir", ws + "/my-app/README.md", "my-app"}, - - {"workspace root file", ws + "/notes.txt", ""}, - - {"outside workspace", "/tmp/foo.txt", ""}, - - {"trailing slash ws", ws + "/projects/terra/src/main.py", "projects/terra/src"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := fileParentRelDir(tt.path, ws) - - if got != tt.want { - t.Errorf("fileParentRelDir(%q, ws) = %q, want %q", tt.path, got, tt.want) - } - }) - } -} - -func TestCommonDirPrefix(t *testing.T) { - tests := []struct { - name string - - a, b string - - want string - }{ - {"same dir", "projects/terra/src", "projects/terra/src", "projects/terra/src"}, - - {"converge to project", "projects/terra/src", "projects/terra/tests", "projects/terra"}, - - {"converge to top", "projects/terra/src", "projects/other/tests", "projects"}, - - {"no common", "aaa/bbb", "ccc/ddd", ""}, - - {"one is prefix", "projects/terra", "projects/terra/src", "projects/terra"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := commonDirPrefix(tt.a, tt.b) - - if got != tt.want { - t.Errorf("commonDirPrefix(%q, %q) = %q, want %q", tt.a, tt.b, got, tt.want) - } - }) - } -} - -func TestDisplayProjectDir(t *testing.T) { - // exec projectDir wins - - task1 := &activeTask{projectDir: "my-app", fileCommonDir: "projects/other"} - - if got := displayProjectDir(task1); got != "my-app" { - t.Errorf("expected 'my-app', got %q", got) - } - - // fileCommonDir fallback: basename - - task2 := &activeTask{fileCommonDir: "projects/terra-py-form"} - - if got := displayProjectDir(task2); got != "terra-py-form" { - t.Errorf("expected 'terra-py-form', got %q", got) - } - - // single component - - task3 := &activeTask{fileCommonDir: "my-app"} - - if got := displayProjectDir(task3); got != "my-app" { - t.Errorf("expected 'my-app', got %q", got) - } - - // empty - - task4 := &activeTask{} - - if got := displayProjectDir(task4); got != "" { - t.Errorf("expected empty, got %q", got) - } -} - -func TestBuildRichStatus_FixedHeight(t *testing.T) { - // Test that output has the same number of lines regardless of entry count - - countLines := func(s string) int { - return strings.Count(s, "\n") - } - - // 0 entries - - task0 := &activeTask{Iteration: 1, MaxIter: 10} - - lines0 := countLines(buildRichStatus(task0, true, "/ws/p")) - - // 1 entry - - task1 := &activeTask{ - Iteration: 1, MaxIter: 10, - - toolLog: []toolLogEntry{{Name: "exec", ArgsSnip: "ls", Result: "⏳"}}, - } - - lines1 := countLines(buildRichStatus(task1, true, "/ws/p")) - - // 5 entries - - task5 := &activeTask{Iteration: 5, MaxIter: 10} - - for i := 0; i < 5; i++ { - task5.toolLog = append(task5.toolLog, toolLogEntry{ - Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s", - }) - } - - lines5 := countLines(buildRichStatus(task5, true, "/ws/p")) - - // 5 entries + sticky error - - task5err := &activeTask{Iteration: 5, MaxIter: 10} - - for i := 0; i < 5; i++ { - task5err.toolLog = append(task5err.toolLog, toolLogEntry{ - Name: fmt.Sprintf("[%d] exec", i), ArgsSnip: "cmd", Result: "✓ 1.0s", - }) - } - - errEntry := toolLogEntry{ - Name: "[3] exec", ArgsSnip: "pytest", Result: "✗ 2.0s", - - ErrDetail: "FAILED test\nExit code: 1", - } - - task5err.lastError = &errEntry - - lines5err := countLines(buildRichStatus(task5err, true, "/ws/p")) - - if lines0 != lines1 || lines1 != lines5 || lines5 != lines5err { - t.Errorf("line counts should be equal: 0=%d, 1=%d, 5=%d, 5+err=%d", - - lines0, lines1, lines5, lines5err) - } -} - -func TestBuildRichStatus_StickyError(t *testing.T) { - // Error from a past entry sticks in the error section - - errEntry := toolLogEntry{ - Name: "[2] exec", ArgsSnip: "pytest", Result: "✗ 3.2s", - - ErrDetail: "FAILED test_login\nExit code: 1", - } - - task := &activeTask{ - Iteration: 5, - - MaxIter: 10, - - toolLog: []toolLogEntry{ - {Name: "[3] read_file", ArgsSnip: "src/auth.py", Result: "✓ 0.1s"}, - - {Name: "[4] edit_file", ArgsSnip: "src/auth.py", Result: "✓ 0.2s"}, - - {Name: "[5] exec", ArgsSnip: "pytest --retry", Result: "⏳"}, - }, - - lastError: &errEntry, - } - - got := buildRichStatus(task, false, "/ws/p") - - // Error section should show the sticky error in code block - - if !strings.Contains(got, "FAILED test_login") { - t.Errorf("expected sticky error detail in error section, got:\n%s", got) - } - - if !strings.Contains(got, "\u274C") { // ❌ - t.Errorf("expected ❌ error header, got:\n%s", got) - } - - // Latest entry is NOT the error - - if !strings.Contains(got, "pytest --retry") { - t.Errorf("expected latest entry command, got:\n%s", got) - } -} - -func TestBuildRichStatus_LatestEntryNoInlineResult(t *testing.T) { - longCmd := "uv run pytest tests/hot/test_state_backend_integration.py" - - task := &activeTask{ - Iteration: 2, - - MaxIter: 10, - - toolLog: []toolLogEntry{ - {Name: "exec", ArgsSnip: "ls -la", Result: "\u2713 0.5s"}, - - {Name: "exec", ArgsSnip: longCmd, Result: "\u23F3"}, - }, - } - - got := buildRichStatus(task, false, "/ws/my-project") - - // Latest entry shows command (possibly truncated) with filename visible - - if !strings.Contains(got, "integration.py") { - t.Errorf("latest entry should show filename, got:\n%s", got) - } - - // Result on separate indented line - - if !strings.Contains(got, " \u23F3") { - t.Errorf("latest entry result should be on indented line, got:\n%s", got) - } - - // Project name shown - - if !strings.Contains(got, "my-project") { - t.Errorf("should show project name, got:\n%s", got) - } - - // No second separator before error section - - lines := strings.Split(got, "\n") - - sepCount := 0 - - for _, l := range lines { - if strings.HasPrefix(l, "\u2501") { - sepCount++ - } - } - - if sepCount != 1 { - t.Errorf("expected exactly 1 separator, got %d in:\n%s", sepCount, got) - } -} - -func TestSanitizeHistoryForProvider_MultiToolCall(t *testing.T) { - // Regression: assistant with 2+ tool_calls had 2nd+ tool results dropped - - // because the check only allowed tool after assistant, not after sibling tool. - - history := []providers.Message{ - {Role: "user", Content: "hello"}, - - {Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{ - {ID: "a", Function: &providers.FunctionCall{Name: "exec"}}, - - {ID: "b", Function: &providers.FunctionCall{Name: "read_file"}}, - }}, - - {Role: "tool", Content: "ok", ToolCallID: "a"}, - - {Role: "tool", Content: "ok", ToolCallID: "b"}, - - {Role: "assistant", Content: "done"}, - } - - got := sanitizeHistoryForProvider(history) - - // All 5 messages must survive - - if len(got) != 5 { - roles := make([]string, len(got)) - - for i, m := range got { - roles[i] = m.Role - } - - t.Fatalf("expected 5 messages, got %d: %v", len(got), roles) - } - - // Verify both tool results present - - toolCount := 0 - - for _, m := range got { - if m.Role == "tool" { - toolCount++ - } - } - - if toolCount != 2 { - t.Errorf("expected 2 tool results, got %d", toolCount) - } -} - -// ---------- plan nudge tests ---------- - -// countingMockProvider counts Chat calls and always returns text-only responses. - -type countingMockProvider struct { - callCount int -} - -func (m *countingMockProvider) Chat( - ctx context.Context, - - messages []providers.Message, - - tools []providers.ToolDefinition, - - model string, - - opts map[string]any, -) (*providers.LLMResponse, error) { - m.callCount++ - - return &providers.LLMResponse{ - Content: fmt.Sprintf("Response %d", m.callCount), - - ToolCalls: []providers.ToolCall{}, - }, nil -} - -func (m *countingMockProvider) GetDefaultModel() string { - return "mock-counting-model" -} - -func TestPlanNudge_ForegroundExecution(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-nudge-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - provider := &countingMockProvider{} - - msgBus := bus.NewMessageBus() - - al := NewAgentLoop(cfg, msgBus, provider) - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - t.Fatal("no default agent") - } - - // Write a plan in executing status with unchecked steps - - plan := "# Active Plan\n\n> Task: Test\n> Status: executing\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n- [ ] Step two\n\n## Context\n" - - agent.ContextBuilder.WriteMemory(plan) - - // Process a foreground message (no background metadata) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - - defer cancel() - - msg := bus.InboundMessage{ - Channel: "test", - - SenderID: "user1", - - ChatID: "chat1", - - Content: "continue working", - - SessionKey: "nudge-test", - } - - _, err = al.processMessage(ctx, msg) - if err != nil { - t.Fatalf("processMessage failed: %v", err) - } - - // The provider should have been called at least 2 times: - - // 1st call: returns text-only → nudge fires (unchecked steps remain) - - // 2nd call: returns text-only → nudge already fired, loop exits - - if provider.callCount < 2 { - t.Errorf("expected at least 2 provider calls (nudge should trigger continuation), got %d", provider.callCount) - } -} - -func TestPlanNudge_NoNudgeWhenAllStepsComplete(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-nudge-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - provider := &countingMockProvider{} - - msgBus := bus.NewMessageBus() - - al := NewAgentLoop(cfg, msgBus, provider) - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - t.Fatal("no default agent") - } - - // Write a plan where all steps are already checked - - plan := "# Active Plan\n\n> Task: Test\n> Status: executing\n> Phase: 1\n\n## Phase 1: Setup\n- [x] Step one\n- [x] Step two\n\n## Context\n" - - agent.ContextBuilder.WriteMemory(plan) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - - defer cancel() - - msg := bus.InboundMessage{ - Channel: "test", - - SenderID: "user1", - - ChatID: "chat1", - - Content: "all done", - - SessionKey: "nudge-test-complete", - } - - _, err = al.processMessage(ctx, msg) - if err != nil { - t.Fatalf("processMessage failed: %v", err) - } - - // No unchecked steps → preUnchecked=0 → no nudge → only 1 provider call - - if provider.callCount != 1 { - t.Errorf("expected exactly 1 provider call (no nudge needed), got %d", provider.callCount) - } -} - -func TestPlanNudge_ProgressMessage(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-nudge-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - - MaxToolIterations: 10, - }, - }, - } - - // Provider that checks the nudge message content on the 2nd call - - var nudgeContent string - - provider := &nudgeCaptureMockProvider{onSecondCall: func(msgs []providers.Message) { - // The last user message should be the nudge - - for i := len(msgs) - 1; i >= 0; i-- { - if msgs[i].Role == "user" { - nudgeContent = msgs[i].Content - - break - } - } - }} - - msgBus := bus.NewMessageBus() - - al := NewAgentLoop(cfg, msgBus, provider) - - agent := al.registry.GetDefaultAgent() - - if agent == nil { - t.Fatal("no default agent") - } - - // Write a plan with 3 unchecked steps; the provider edits memory to - - // mark one step between calls (simulated by the first-call hook). - - plan := "# Active Plan\n\n> Task: Test\n> Status: executing\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n- [ ] Step two\n- [ ] Step three\n\n## Context\n" - - agent.ContextBuilder.WriteMemory(plan) - - // After the first LLM response (no tool calls), simulate that - - // one step was marked [x] externally (as if the AI did it via tool). - - // We do this by hooking the provider's first call to mutate memory. - - provider.onFirstCall = func() { - updated := strings.Replace(agent.ContextBuilder.ReadMemory(), "- [ ] Step one", "- [x] Step one", 1) - - agent.ContextBuilder.WriteMemory(updated) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - - defer cancel() - - msg := bus.InboundMessage{ - Channel: "test", - - SenderID: "user1", - - ChatID: "chat1", - - Content: "work on the plan", - - SessionKey: "nudge-progress-test", - } - - _, err = al.processMessage(ctx, msg) - if err != nil { - t.Fatalf("processMessage failed: %v", err) - } - - // Should have gotten the "Progress recorded" nudge (not the "none were marked" one) - - if !strings.Contains(nudgeContent, "Progress recorded") { - t.Errorf("expected 'Progress recorded' nudge, got %q", nudgeContent) - } - - if !strings.Contains(nudgeContent, "2 unchecked steps remain") { - t.Errorf("expected '2 unchecked steps remain' in nudge, got %q", nudgeContent) - } -} - -// nudgeCaptureMockProvider calls hooks on 1st and 2nd Chat invocations. - -type nudgeCaptureMockProvider struct { - callCount int - - onFirstCall func() - - onSecondCall func([]providers.Message) -} - -func (m *nudgeCaptureMockProvider) Chat( - ctx context.Context, - - messages []providers.Message, - - tools []providers.ToolDefinition, - - model string, - - opts map[string]any, -) (*providers.LLMResponse, error) { - m.callCount++ - - if m.callCount == 1 && m.onFirstCall != nil { - m.onFirstCall() - } - - if m.callCount == 2 && m.onSecondCall != nil { - m.onSecondCall(messages) - } - - return &providers.LLMResponse{ - Content: fmt.Sprintf("Response %d", m.callCount), - - ToolCalls: []providers.ToolCall{}, - }, nil -} - -func (m *nudgeCaptureMockProvider) GetDefaultModel() string { - return "mock-nudge-model" -} - -// --- consumeStreamWithRepetitionDetection tests --- - -func TestConsumeStream_NormalCompletion(t *testing.T) { - ch := make(chan protocoltypes.StreamEvent, 8) - - go func() { - ch <- protocoltypes.StreamEvent{ContentDelta: "Hello "} - - ch <- protocoltypes.StreamEvent{ContentDelta: "world!"} - - ch <- protocoltypes.StreamEvent{ - FinishReason: "stop", - - Usage: &providers.UsageInfo{PromptTokens: 5, CompletionTokens: 2, TotalTokens: 7}, - } - - close(ch) - }() - - ctx, cancel := context.WithCancel(context.Background()) - - defer cancel() - - resp, detected, err := consumeStreamWithRepetitionDetection(ch, cancel, 1000, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if detected { - t.Fatal("expected detected=false for normal content") - } - - if resp.Content != "Hello world!" { - t.Errorf("Content = %q, want %q", resp.Content, "Hello world!") - } - - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - - if resp.Usage == nil || resp.Usage.TotalTokens != 7 { - t.Errorf("Usage.TotalTokens = %v, want 7", resp.Usage) - } - - _ = ctx // keep linter happy -} - -func TestConsumeStream_DetectsRepetition(t *testing.T) { - ch := make(chan protocoltypes.StreamEvent, 64) - - cancelCalled := false - - ctx, cancel := context.WithCancel(context.Background()) - - wrappedCancel := func() { - cancelCalled = true - - cancel() - } - - // Send enough repetitive content to trigger detection. - - // The pattern "abcdefghij" repeated many times will have very low n-gram uniqueness. - - repeatedChunk := strings.Repeat("abcdefghij", 50) // 500 chars per chunk - - go func() { - // Send 6 chunks of repetitive content = 3000 chars total, - - // each with 500 runes. The check triggers after every 1000 runes - - // when content > 2000 chars. - - for i := 0; i < 6; i++ { - ch <- protocoltypes.StreamEvent{ContentDelta: repeatedChunk} - } - - // Send more data that should be ignored after detection. - - for i := 0; i < 10; i++ { - ch <- protocoltypes.StreamEvent{ContentDelta: "more data"} - } - - close(ch) - }() - - resp, detected, err := consumeStreamWithRepetitionDetection(ch, wrappedCancel, 1000, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !detected { - t.Fatal("expected repetition detection to trigger") - } - - if !cancelCalled { - t.Error("expected cancelFn to be called") - } - - // The response should be shorter than the full 3000+ chars - - // because detection triggers early. - - if len(resp.Content) >= 3000+10*len("more data") { - t.Errorf("Content length = %d, expected less than full output", len(resp.Content)) - } - - _ = ctx -} - -func TestConsumeStream_ToolCallAccumulation(t *testing.T) { - ch := make(chan protocoltypes.StreamEvent, 8) - - go func() { - ch <- protocoltypes.StreamEvent{ - ToolCallDeltas: []protocoltypes.StreamToolCallDelta{ - {Index: 0, ID: "call_1", Name: "test_fn", ArgumentsDelta: `{"ke`}, - }, - } - - ch <- protocoltypes.StreamEvent{ - ToolCallDeltas: []protocoltypes.StreamToolCallDelta{ - {Index: 0, ArgumentsDelta: `y":"val"}`}, - }, - } - - ch <- protocoltypes.StreamEvent{FinishReason: "tool_calls"} - - close(ch) - }() - - _, cancel := context.WithCancel(context.Background()) - - defer cancel() - - resp, detected, err := consumeStreamWithRepetitionDetection(ch, cancel, 1000, nil) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if detected { - t.Fatal("expected no repetition detection for tool calls") - } - - if len(resp.ToolCalls) != 1 { - t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls)) - } - - if resp.ToolCalls[0].Name != "test_fn" { - t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "test_fn") - } - - if resp.ToolCalls[0].Arguments["key"] != "val" { - t.Errorf("ToolCalls[0].Arguments[key] = %v, want %q", resp.ToolCalls[0].Arguments["key"], "val") - } -} - -func TestConsumeStream_StreamError(t *testing.T) { - ch := make(chan protocoltypes.StreamEvent, 4) - - go func() { - ch <- protocoltypes.StreamEvent{ContentDelta: "partial"} - - ch <- protocoltypes.StreamEvent{Err: fmt.Errorf("read error")} - - close(ch) - }() - - _, cancel := context.WithCancel(context.Background()) - - defer cancel() - - _, _, err := consumeStreamWithRepetitionDetection(ch, cancel, 1000, nil) - - if err == nil { - t.Fatal("expected error, got nil") - } - - if !strings.Contains(err.Error(), "read error") { - t.Errorf("error = %q, want to contain %q", err.Error(), "read error") - } -} - -func TestConsumeStream_OnChunkCallback(t *testing.T) { - ch := make(chan protocoltypes.StreamEvent, 8) - - go func() { - ch <- protocoltypes.StreamEvent{ContentDelta: "Hello "} - - ch <- protocoltypes.StreamEvent{ContentDelta: "world"} - - ch <- protocoltypes.StreamEvent{ContentDelta: "!"} - - ch <- protocoltypes.StreamEvent{FinishReason: "stop"} - - close(ch) - }() - - _, cancel := context.WithCancel(context.Background()) - - defer cancel() - - var chunks []string - - onChunk := func(accumulated, _ string) { - chunks = append(chunks, accumulated) - } - - resp, detected, err := consumeStreamWithRepetitionDetection(ch, cancel, 1000, onChunk) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if detected { - t.Fatal("expected detected=false") - } - - if resp.Content != "Hello world!" { - t.Errorf("Content = %q, want %q", resp.Content, "Hello world!") - } - - // onChunk should be called once per content delta (3 times) - - if len(chunks) != 3 { - t.Fatalf("onChunk called %d times, want 3", len(chunks)) - } - - if chunks[0] != "Hello " { - t.Errorf("chunks[0] = %q, want %q", chunks[0], "Hello ") - } - - if chunks[1] != "Hello world" { - t.Errorf("chunks[1] = %q, want %q", chunks[1], "Hello world") - } - - if chunks[2] != "Hello world!" { - t.Errorf("chunks[2] = %q, want %q", chunks[2], "Hello world!") - } -} - -func TestConsumeStream_OnChunkWithRepetitionDetection(t *testing.T) { - ch := make(chan protocoltypes.StreamEvent, 64) - - cancelCalled := false - - ctx, cancel := context.WithCancel(context.Background()) - - wrappedCancel := func() { - cancelCalled = true - - cancel() - } - - repeatedChunk := strings.Repeat("abcdefghij", 50) // 500 chars per chunk - - go func() { - for i := 0; i < 6; i++ { - ch <- protocoltypes.StreamEvent{ContentDelta: repeatedChunk} - } - - for i := 0; i < 10; i++ { - ch <- protocoltypes.StreamEvent{ContentDelta: "more data"} - } - - close(ch) - }() - - var chunkCount int - - onChunk := func(_, _ string) { - chunkCount++ - } - - _, detected, err := consumeStreamWithRepetitionDetection(ch, wrappedCancel, 1000, onChunk) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - if !detected { - t.Fatal("expected repetition detection to trigger") - } - - if !cancelCalled { - t.Error("expected cancelFn to be called") - } - - // onChunk should have been called at least once before detection - - if chunkCount == 0 { - t.Error("expected onChunk to be called at least once") - } - - _ = ctx -} - -// modelCapturingMockProvider records which model was passed to Chat. - -type modelCapturingMockProvider struct { - mu sync.Mutex - - models []string - - response string -} - -func (m *modelCapturingMockProvider) Chat( - ctx context.Context, - - messages []providers.Message, - - tools_ []providers.ToolDefinition, - - model string, - - opts map[string]any, -) (*providers.LLMResponse, error) { - m.mu.Lock() - - m.models = append(m.models, model) - - m.mu.Unlock() - - return &providers.LLMResponse{ - Content: m.response, - - ToolCalls: []providers.ToolCall{}, - }, nil -} - -func (m *modelCapturingMockProvider) GetDefaultModel() string { - return "mock-capture-model" -} - -func TestAgentLoop_PlanModel_UsedDuringInterviewing(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-planmodel-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "normal-model", - - PlanModel: "plan-model", - - MaxTokens: 4096, - - MaxToolIterations: 2, - }, - }, - } - - msgBus := bus.NewMessageBus() - - provider := &modelCapturingMockProvider{response: "Plan interview response"} - - al := NewAgentLoop(cfg, msgBus, provider) - - defaultAgent := al.registry.GetDefaultAgent() - - if defaultAgent == nil { - t.Fatal("No default agent found") - } - - // Write MEMORY.md with interviewing status to activate plan model - - memoryDir := filepath.Join(tmpDir, "memory") - - os.MkdirAll(memoryDir, 0o755) - - memoryPath := filepath.Join(memoryDir, "MEMORY.md") - - memoryContent := "# Active Plan\n\n> Task: Test plan model\n> Status: interviewing\n> Phase: 1\n" - - if wErr := os.WriteFile(memoryPath, []byte(memoryContent), 0o644); wErr != nil { - t.Fatalf("Failed to write MEMORY.md: %v", wErr) - } - - _, err = al.ProcessDirectWithChannel( - - context.Background(), - - "Hello, plan model test", - - "test-plan-session", - - "test", - - "test-chat", - ) - if err != nil { - t.Fatalf("ProcessDirectWithChannel failed: %v", err) - } - - provider.mu.Lock() - - defer provider.mu.Unlock() - - if len(provider.models) == 0 { - t.Fatal("Expected at least one Chat call") - } - - // The first call should use the plan model since we're in interviewing state - - if provider.models[0] != "plan-model" { - t.Errorf("Expected plan model 'plan-model' during interviewing, got %q", provider.models[0]) - } -} - -func TestAgentLoop_PlanModel_NotUsedDuringExecuting(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-planmodel-exec-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "normal-model", - - PlanModel: "plan-model", - - MaxTokens: 4096, - - MaxToolIterations: 2, - }, - }, - } - - msgBus := bus.NewMessageBus() - - provider := &modelCapturingMockProvider{response: "Executing response"} - - al := NewAgentLoop(cfg, msgBus, provider) - - defaultAgent := al.registry.GetDefaultAgent() - - if defaultAgent == nil { - t.Fatal("No default agent found") - } - - // Write MEMORY.md with executing status - should use normal model - - memoryDir := filepath.Join(tmpDir, "memory") - - os.MkdirAll(memoryDir, 0o755) - - memoryPath := filepath.Join(memoryDir, "MEMORY.md") - - memoryContent := `# Active Plan - - - -> Task: Test plan model - -> Status: executing - -> Phase: 1 - - - -## Phase 1: Build - -- [ ] Run build - -` - - if wErr := os.WriteFile(memoryPath, []byte(memoryContent), 0o644); wErr != nil { - t.Fatalf("Failed to write MEMORY.md: %v", wErr) - } - - _, err = al.ProcessDirectWithChannel( - - context.Background(), - - "Hello, executing test", - - "test-exec-session", - - "test", - - "test-chat", - ) - if err != nil { - t.Fatalf("ProcessDirectWithChannel failed: %v", err) - } - - provider.mu.Lock() - - defer provider.mu.Unlock() - - if len(provider.models) == 0 { - t.Fatal("Expected at least one Chat call") - } - - // During executing phase, should use normal model, not plan model - - if provider.models[0] != "normal-model" { - t.Errorf("Expected normal model 'normal-model' during executing, got %q", provider.models[0]) - } -} - -func TestAgentLoop_PlanModel_ResolvesProviderForSingleCandidate(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "agent-test-planmodel-resolve-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - - defer os.RemoveAll(tmpDir) - - cfg := &config.Config{ - Agents: config.AgentsConfig{ - Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "MiniMax-M2.5", - - PlanModel: "openai/gpt-5.2", - - MaxTokens: 4096, - - MaxToolIterations: 2, - }, - }, - } - - msgBus := bus.NewMessageBus() - - // The main provider simulates the wrong provider (e.g. MiniMax). - - mainProvider := &modelCapturingMockProvider{response: "wrong provider response"} - - al := NewAgentLoop(cfg, msgBus, mainProvider) - - // Inject a mock provider into the cache so resolveProvider returns it - - // for the "openai/gpt-5.2" candidate (provider="openai", model="gpt-5.2"). - - resolvedProvider := &modelCapturingMockProvider{response: "correct provider response"} - - al.providerCache["openai/gpt-5.2"] = resolvedProvider - - // Write MEMORY.md with interviewing status to activate plan model - - memoryDir := filepath.Join(tmpDir, "memory") - - os.MkdirAll(memoryDir, 0o755) - - memoryPath := filepath.Join(memoryDir, "MEMORY.md") - - memoryContent := "# Active Plan\n\n> Task: Test provider resolution\n> Status: interviewing\n> Phase: 1\n" - - if wErr := os.WriteFile(memoryPath, []byte(memoryContent), 0o644); wErr != nil { - t.Fatalf("Failed to write MEMORY.md: %v", wErr) - } - - _, err = al.ProcessDirectWithChannel( - - context.Background(), - - "Hello, resolve provider test", - - "test-resolve-session", - - "test", - - "test-chat", - ) - if err != nil { - t.Fatalf("ProcessDirectWithChannel failed: %v", err) - } - - resolvedProvider.mu.Lock() - - defer resolvedProvider.mu.Unlock() - - mainProvider.mu.Lock() - - defer mainProvider.mu.Unlock() - - // The resolved provider should have been called with the stripped model name - - if len(resolvedProvider.models) == 0 { - t.Fatal("Expected resolved provider to receive Chat call, but it got none") - } - - if resolvedProvider.models[0] != "gpt-5.2" { - t.Errorf("Expected resolved provider to receive model 'gpt-5.2', got %q", resolvedProvider.models[0]) - } - - // The main provider should NOT have been called for the LLM request - - if len(mainProvider.models) > 0 { - t.Errorf("Expected main provider to receive no Chat calls during plan model phase, got %d calls with models %v", - - len(mainProvider.models), mainProvider.models) - } -} - -func TestPlanCommand_StartClear(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - agent := al.registry.GetDefaultAgent() - - // Create a plan in review status with phases - - plan := "# Active Plan\n\n> Task: Test task\n> Status: review\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" - - _ = agent.ContextBuilder.WriteMemory(plan) - - // Seed session history so we can verify it gets cleared - - agent.Sessions.AddMessage("test-session", "user", "hello") - - agent.Sessions.AddMessage("test-session", "assistant", "world") - - agent.Sessions.SetSummary("test-session", "some summary") - - // Approve with clear - - response, handled := al.handleCommand(context.Background(), bus.InboundMessage{ - Content: "/plan start clear", - - SessionKey: "test-session", - }) - - if !handled { - t.Fatal("expected /plan start clear to be handled") - } - - if !strings.Contains(response, "clean history") { - t.Errorf("expected 'clean history' in response, got %q", response) - } - - if !al.planStartPending { - t.Error("expected planStartPending to be true") - } - - if !al.planClearHistory { - t.Error("expected planClearHistory to be true") - } - - // Simulate what Run() does when planStartPending is set - - al.planStartPending = false - - clearHistory := al.planClearHistory - - al.planClearHistory = false - - if clearHistory { - agent.Sessions.SetHistory("test-session", nil) - - agent.Sessions.SetSummary("test-session", "") - - _ = agent.Sessions.Save("test-session") - } - - // Verify history and summary are cleared - - history := agent.Sessions.GetHistory("test-session") - - if len(history) != 0 { - t.Errorf("expected empty history after clear, got %d messages", len(history)) - } - - summary := agent.Sessions.GetSummary("test-session") - - if summary != "" { - t.Errorf("expected empty summary after clear, got %q", summary) - } -} - -func TestPlanCommand_StartWithoutClear_PreservesHistory(t *testing.T) { - al, cleanup := newTestAgentLoop(t) - - defer cleanup() - - agent := al.registry.GetDefaultAgent() - - // Create a plan in review status with phases - - plan := "# Active Plan\n\n> Task: Test task\n> Status: review\n> Phase: 1\n\n## Phase 1: Setup\n- [ ] Step one\n\n## Context\n" - - _ = agent.ContextBuilder.WriteMemory(plan) - - // Seed session history - - agent.Sessions.AddMessage("test-session", "user", "hello") - - agent.Sessions.AddMessage("test-session", "assistant", "world") - - agent.Sessions.SetSummary("test-session", "some summary") - - // Approve without clear - - response, _ := al.handleCommand(context.Background(), bus.InboundMessage{ - Content: "/plan start", - - SessionKey: "test-session", - }) - - if strings.Contains(response, "clean history") { - t.Errorf("did not expect 'clean history' in response, got %q", response) - } - - if al.planClearHistory { - t.Error("planClearHistory should be false for /plan start without clear") - } - - // Verify history is preserved - - history := agent.Sessions.GetHistory("test-session") - - if len(history) != 2 { - t.Errorf("expected 2 history messages preserved, got %d", len(history)) - } - - summary := agent.Sessions.GetSummary("test-session") - - if summary != "some summary" { - t.Errorf("expected summary preserved, got %q", summary) - } -} - -func TestFilterInterviewTools(t *testing.T) { - allDefs := []providers.ToolDefinition{ - {Function: protocoltypes.ToolFunctionDefinition{Name: "read_file"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "list_dir"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "web_search"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "web_fetch"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "message"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "edit_file"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "append_file"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "write_file"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "exec"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "logs"}}, - - // These should be filtered out: - - {Function: protocoltypes.ToolFunctionDefinition{Name: "spawn_subagent"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "skills_search"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "skills_install"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "bg_monitor"}}, - - {Function: protocoltypes.ToolFunctionDefinition{Name: "i2c_transfer"}}, - } - - filtered := filterInterviewTools(allDefs) - - // Should keep exactly the 10 allowed tools - - if len(filtered) != 10 { - names := make([]string, len(filtered)) - - for i, d := range filtered { - names[i] = d.Function.Name - } - - t.Errorf("expected 10 allowed tools, got %d: %v", len(filtered), names) - } - - // Verify none of the disallowed tools slipped through - - disallowed := map[string]bool{ - "spawnsubagent": true, "skillssearch": true, - - "skillsinstall": true, "bgmonitor": true, "ictransfer": true, - } - - for _, d := range filtered { - norm := tools.NormalizeToolName(d.Function.Name) - - if disallowed[norm] { - t.Errorf("disallowed tool %q should have been filtered out", d.Function.Name) - } - } -} - -func TestBuildStreamingDisplay_ContentOnly(t *testing.T) { - display := buildStreamingDisplay("hello world", "") - - if !strings.HasSuffix(display, " \u2589") { - t.Error("expected cursor suffix") - } - - if strings.Contains(display, "\U0001f9e0") { - t.Error("should not contain brain emoji when no reasoning") - } - - lines := strings.Count(display, "\n") + 1 - - if lines != streamingDisplayLines+1 { // TailPad lines + cursor on last line - t.Logf("display:\n%s", display) - } -} - -func TestBuildStreamingDisplay_ReasoningOnly(t *testing.T) { - display := buildStreamingDisplay("", "let me think about this") - - if !strings.Contains(display, "\U0001f9e0") { - t.Error("expected brain emoji for reasoning phase") - } - - if !strings.Contains(display, "Thinking...") { - t.Error("expected Thinking... header") - } - - if !strings.HasSuffix(display, " \u2589") { - t.Error("expected cursor suffix") - } -} - -func TestBuildStreamingDisplay_Both(t *testing.T) { - display := buildStreamingDisplay("the answer is 42", "first I considered...") - - if !strings.Contains(display, "\U0001f9e0") { - t.Error("expected brain emoji") - } - - if !strings.Contains(display, "responding") { - t.Error("expected responding header when both present") - } - - if !strings.Contains(display, "the answer is 42") { - t.Error("expected content in display") - } -} - func TestHandleReasoning(t *testing.T) { newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) { t.Helper() - tmpDir, err := os.MkdirTemp("", "agent-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } - t.Cleanup(func() { _ = os.RemoveAll(tmpDir) }) - cfg := &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: tmpDir, - - Model: "test-model", - - MaxTokens: 4096, - + Workspace: tmpDir, + Model: "test-model", + MaxTokens: 4096, MaxToolIterations: 10, }, }, } - msgBus := bus.NewMessageBus() - return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus } t.Run("skips when any required field is empty", func(t *testing.T) { al, msgBus := newLoop(t) - al.handleReasoning(context.Background(), "reasoning", "telegram", "") ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) - defer cancel() - if msg, ok := msgBus.SubscribeOutbound(ctx); ok { t.Fatalf("expected no outbound message, got %+v", msg) } @@ -4112,19 +924,14 @@ func TestHandleReasoning(t *testing.T) { t.Run("publishes one message for non telegram", func(t *testing.T) { al, msgBus := newLoop(t) - al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1") ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - msg, ok := msgBus.SubscribeOutbound(ctx) - if !ok { t.Fatal("expected an outbound message") } - if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" { t.Fatalf("unexpected outbound message: %+v", msg) } @@ -4132,17 +939,12 @@ func TestHandleReasoning(t *testing.T) { t.Run("publishes one message for telegram", func(t *testing.T) { al, msgBus := newLoop(t) - reasoning := "hello telegram reasoning" - al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat") ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - msg, ok := msgBus.SubscribeOutbound(ctx) - if !ok { t.Fatal("expected outbound message") } @@ -4150,33 +952,23 @@ func TestHandleReasoning(t *testing.T) { if msg.Channel != "telegram" { t.Fatalf("expected telegram channel message, got %+v", msg) } - if msg.ChatID != "tg-chat" { t.Fatalf("expected chatID tg-chat, got %+v", msg) } - if msg.Content != reasoning { t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning) } }) - t.Run("expired ctx", func(t *testing.T) { al, msgBus := newLoop(t) - reasoning := "hello telegram reasoning" - ctx, cancel := context.WithCancel(context.Background()) - cancel() - al.handleReasoning(ctx, reasoning, "telegram", "tg-chat") ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond) - defer cancel() - msg, ok := msgBus.SubscribeOutbound(ctx) - if ok { t.Fatalf("expected no outbound message, got %+v", msg) } @@ -4186,209 +978,191 @@ func TestHandleReasoning(t *testing.T) { al, msgBus := newLoop(t) // Fill the outbound bus buffer until a publish would block. - // Use a short timeout to detect when the buffer is full, - // rather than hardcoding the buffer size. - for i := 0; ; i++ { fillCtx, fillCancel := context.WithTimeout(context.Background(), 50*time.Millisecond) - err := msgBus.PublishOutbound(fillCtx, bus.OutboundMessage{ Channel: "filler", - - ChatID: "filler", - + ChatID: "filler", Content: fmt.Sprintf("filler-%d", i), }) - fillCancel() - if err != nil { // Buffer is full (timed out trying to send). - break } } // Use a short-deadline parent context to bound the test. - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() start := time.Now() - al.handleReasoning(ctx, "should timeout", "slack", "channel-full") - elapsed := time.Since(start) // handleReasoning uses a 5s internal timeout, but the parent ctx - // expires in 500ms. It should return within ~500ms, not 5s. - if elapsed > 2*time.Second { t.Fatalf("handleReasoning blocked too long (%v); expected prompt return", elapsed) } // Drain the bus and verify the reasoning message was NOT published - // (it should have been dropped due to timeout). - drainCtx, drainCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer drainCancel() - foundReasoning := false - for { msg, ok := msgBus.SubscribeOutbound(drainCtx) - if !ok { break } - if msg.Content == "should timeout" { foundReasoning = true } } - if foundReasoning { t.Fatal("expected reasoning message to be dropped when bus is full, but it was published") } }) } -func TestFormatDurationMs(t *testing.T) { - tests := []struct { - ms int64 +func TestResolveMediaRefs_ResolvesToBase64(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() - want string - }{ - {0, "0ms"}, - - {500, "500ms"}, - - {999, "999ms"}, - - {1000, "1.0s"}, - - {1200, "1.2s"}, - - {3500, "3.5s"}, - - {59900, "59.9s"}, - - {60000, "1m"}, - - {61000, "1m1s"}, - - {65000, "1m5s"}, - - {120000, "2m"}, - - {3661000, "61m1s"}, + // Create a minimal valid PNG (8-byte header is enough for filetype detection) + pngPath := filepath.Join(dir, "test.png") + // PNG magic: 0x89 P N G \r \n 0x1A \n + minimal IHDR + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature + 0x00, 0x00, 0x00, 0x0D, // IHDR length + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, // 1x1 RGB + 0x00, 0x00, 0x00, // no interlace + 0x90, 0x77, 0x53, 0xDE, // CRC + } + if err := os.WriteFile(pngPath, pngHeader, 0o644); err != nil { + t.Fatal(err) + } + ref, err := store.Store(pngPath, media.MediaMeta{}, "test") + if err != nil { + t.Fatal(err) } - for _, tt := range tests { - t.Run(fmt.Sprintf("%dms", tt.ms), func(t *testing.T) { - got := formatDurationMs(tt.ms) + messages := []providers.Message{ + {Role: "user", Content: "describe this", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) - if got != tt.want { - t.Errorf("formatDurationMs(%d) = %q, want %q", tt.ms, got, tt.want) - } - }) + if len(result[0].Media) != 1 { + t.Fatalf("expected 1 resolved media, got %d", len(result[0].Media)) + } + if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") { + t.Fatalf("expected data:image/png;base64, prefix, got %q", result[0].Media[0][:40]) } } -func TestFormatSubagentCompletion(t *testing.T) { - tests := []struct { - name string +func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() - label string - - metadata map[string]string - - want string - }{ - { - "no metadata", - - "scout-1", - - nil, - - "📋 scout-1 completed.", - }, - - { - "empty metadata", - - "scout-1", - - map[string]string{}, - - "📋 scout-1 completed.", - }, - - { - "duration and tool calls", - - "scout-1", - - map[string]string{"duration_ms": "3200", "tool_calls": "5"}, - - "📋 scout-1 completed (3.2s, 5 tool calls).", - }, - - { - "single tool call", - - "coder-1", - - map[string]string{"duration_ms": "1200", "tool_calls": "1"}, - - "📋 coder-1 completed (1.2s, 1 tool call).", - }, - - { - "duration only", - - "scout-2", - - map[string]string{"duration_ms": "65000", "tool_calls": "0"}, - - "📋 scout-2 completed (1m5s).", - }, - - { - "tool calls only", - - "scout-3", - - map[string]string{"duration_ms": "0", "tool_calls": "10"}, - - "📋 scout-3 completed (10 tool calls).", - }, - - { - "zero everything", - - "scout-4", - - map[string]string{"duration_ms": "0", "tool_calls": "0"}, - - "📋 scout-4 completed.", - }, + bigPath := filepath.Join(dir, "big.png") + // Write PNG header + padding to exceed limit + data := make([]byte, 1024+1) // 1KB + 1 byte + copy(data, []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) + if err := os.WriteFile(bigPath, data, 0o644); err != nil { + t.Fatal(err) } + ref, _ := store.Store(bigPath, media.MediaMeta{}, "test") - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := formatSubagentCompletion(tt.label, tt.metadata) + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + // Use a tiny limit (1KB) so the file is oversized + result := resolveMediaRefs(messages, store, 1024) - if got != tt.want { - t.Errorf("formatSubagentCompletion(%q, %v) = %q, want %q", tt.label, tt.metadata, got, tt.want) - } - }) + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (oversized), got %d", len(result[0].Media)) + } +} + +func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + txtPath := filepath.Join(dir, "readme.txt") + if err := os.WriteFile(txtPath, []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + ref, _ := store.Store(txtPath, media.MediaMeta{}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 0 { + t.Fatalf("expected 0 media (unknown type), got %d", len(result[0].Media)) + } +} + +func TestResolveMediaRefs_PassesThroughNonMediaRefs(t *testing.T) { + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{"https://example.com/img.png"}}, + } + result := resolveMediaRefs(messages, nil, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 1 || result[0].Media[0] != "https://example.com/img.png" { + t.Fatalf("expected passthrough of non-media:// URL, got %v", result[0].Media) + } +} + +func TestResolveMediaRefs_DoesNotMutateOriginal(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + pngPath := filepath.Join(dir, "test.png") + pngHeader := []byte{ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, + 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE, + } + os.WriteFile(pngPath, pngHeader, 0o644) + ref, _ := store.Store(pngPath, media.MediaMeta{}, "test") + + original := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + originalRef := original[0].Media[0] + + resolveMediaRefs(original, store, config.DefaultMaxMediaSize) + + if original[0].Media[0] != originalRef { + t.Fatal("resolveMediaRefs mutated original message slice") + } +} + +func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { + store := media.NewFileMediaStore() + dir := t.TempDir() + + // File with JPEG content but stored with explicit content type + jpegPath := filepath.Join(dir, "photo") + jpegHeader := []byte{0xFF, 0xD8, 0xFF, 0xE0} // JPEG magic bytes + os.WriteFile(jpegPath, jpegHeader, 0o644) + ref, _ := store.Store(jpegPath, media.MediaMeta{ContentType: "image/jpeg"}, "test") + + messages := []providers.Message{ + {Role: "user", Content: "hi", Media: []string{ref}}, + } + result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) + + if len(result[0].Media) != 1 { + t.Fatalf("expected 1 media, got %d", len(result[0].Media)) + } + if !strings.HasPrefix(result[0].Media[0], "data:image/jpeg;base64,") { + t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) } } diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 2d81bb398..329088797 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -1,11 +1,7 @@ // 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 @@ -14,8 +10,6 @@ import ( "fmt" "os" "path/filepath" - "regexp" - "strconv" "strings" "sync" "time" @@ -24,406 +18,115 @@ import ( ) // MemoryStore manages persistent memory for the agent. - // - Long-term memory: memory/MEMORY.md - // - Daily notes: memory/YYYYMM/YYYYMMDD.md - type MemoryStore struct { - workspace string - - memoryDir string - + workspace string + memoryDir string memoryFile string - cacheMu sync.RWMutex - - longTermCache longTermFileCache - + cacheMu sync.RWMutex + longTermCache longTermFileCache parsedPlanCache parsedPlanStateCache } -type longTermFileCache struct { - loaded bool - - exists bool - - modTime time.Time - - size int64 - - content string -} - -type parsedPlanStateCache struct { - loaded bool - - sourceContent string - - state parsedPlanState -} - -type parsedPlanState struct { - content string - - hasActivePlan bool - - status string - - currentPhase int - - totalPhases int - - workDir string - - taskName string - - phases []PlanPhase -} - // NewMemoryStore creates a new MemoryStore with the given workspace path. - // It ensures the memory directory exists. - func NewMemoryStore(workspace string) *MemoryStore { memoryDir := filepath.Join(workspace, "memory") - memoryFile := filepath.Join(memoryDir, "MEMORY.md") // Ensure memory directory exists - os.MkdirAll(memoryDir, 0o755) return &MemoryStore{ - workspace: workspace, - - memoryDir: memoryDir, - + workspace: workspace, + memoryDir: memoryDir, memoryFile: memoryFile, } } // getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md). - func (ms *MemoryStore) getTodayFile() string { today := time.Now().Format("20060102") // YYYYMMDD - - monthDir := today[:6] // YYYYMM - + monthDir := today[:6] // YYYYMM filePath := filepath.Join(ms.memoryDir, monthDir, today+".md") - return filePath } -// InvalidateCache clears all in-memory caches for MEMORY.md content and parsed plan state. - -func (ms *MemoryStore) InvalidateCache() { - ms.cacheMu.Lock() - - defer ms.cacheMu.Unlock() - - ms.longTermCache = longTermFileCache{} - - ms.parsedPlanCache = parsedPlanStateCache{} -} - -func (ms *MemoryStore) readLongTermCached() string { - info, err := os.Stat(ms.memoryFile) - if err != nil { - if !os.IsNotExist(err) { - return "" - } - - ms.cacheMu.RLock() - - cachedMissing := ms.longTermCache.loaded && !ms.longTermCache.exists - - ms.cacheMu.RUnlock() - - if cachedMissing { - return "" - } - - ms.cacheMu.Lock() - - ms.longTermCache = longTermFileCache{loaded: true, exists: false} - - ms.parsedPlanCache = parsedPlanStateCache{} - - ms.cacheMu.Unlock() - - return "" - } - - modTime := info.ModTime() - - size := info.Size() - - ms.cacheMu.RLock() - - if ms.longTermCache.loaded && - - ms.longTermCache.exists && - - ms.longTermCache.modTime.Equal(modTime) && - - ms.longTermCache.size == size { - content := ms.longTermCache.content - - ms.cacheMu.RUnlock() - - return content - } - - ms.cacheMu.RUnlock() - - data, err := os.ReadFile(ms.memoryFile) - if err != nil { - if os.IsNotExist(err) { - ms.cacheMu.Lock() - - ms.longTermCache = longTermFileCache{loaded: true, exists: false} - - ms.parsedPlanCache = parsedPlanStateCache{} - - ms.cacheMu.Unlock() - } - - return "" - } - - content := string(data) - - ms.cacheMu.Lock() - - ms.longTermCache = longTermFileCache{ - loaded: true, - - exists: true, - - modTime: modTime, - - size: size, - - content: content, - } - - if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent != content { - ms.parsedPlanCache = parsedPlanStateCache{} - } - - ms.cacheMu.Unlock() - - return content -} - -func (ms *MemoryStore) getParsedPlanState() parsedPlanState { - content := ms.ReadLongTerm() - - ms.cacheMu.RLock() - - if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent == content { - state := ms.parsedPlanCache.state - - ms.cacheMu.RUnlock() - - return state - } - - ms.cacheMu.RUnlock() - - state := ms.parsePlanState(content) - - ms.cacheMu.Lock() - - if !ms.parsedPlanCache.loaded || ms.parsedPlanCache.sourceContent != content { - ms.parsedPlanCache = parsedPlanStateCache{ - loaded: true, - - sourceContent: content, - - state: state, - } - } else { - state = ms.parsedPlanCache.state - } - - ms.cacheMu.Unlock() - - return state -} - -func (ms *MemoryStore) parsePlanState(content string) parsedPlanState { - state := parsedPlanState{content: content} - - if content == "" || !reActivePlan.MatchString(content) { - return state - } - - state.hasActivePlan = true - - if m := reStatus.FindStringSubmatch(content); len(m) >= 2 { - state.status = strings.TrimSpace(m[1]) - } - - if m := rePhase.FindStringSubmatch(content); len(m) >= 2 { - state.currentPhase, _ = strconv.Atoi(m[1]) - } - - state.totalPhases = maxPhaseNumber(content) - - if m := reWorkDir.FindStringSubmatch(content); len(m) >= 2 { - state.workDir = strings.TrimSpace(m[1]) - } - - if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 { - state.taskName = strings.TrimSpace(m[1]) - } - - state.phases = ms.getPlanPhasesFrom(content) - - return state -} - -func clonePlanPhases(phases []PlanPhase) []PlanPhase { - if len(phases) == 0 { - return nil - } - - result := make([]PlanPhase, 0, len(phases)) - - for _, p := range phases { - phase := PlanPhase{ - Number: p.Number, - - Title: p.Title, - } - - if len(p.Steps) > 0 { - phase.Steps = append([]PlanStep(nil), p.Steps...) - } - - result = append(result, phase) - } - - return result -} - // ReadLongTerm reads the long-term memory (MEMORY.md). - // Returns empty string if the file doesn't exist. - +// Uses stat-based caching to avoid redundant disk reads. func (ms *MemoryStore) ReadLongTerm() string { return ms.readLongTermCached() } // WriteLongTerm writes content to the long-term memory file (MEMORY.md). - func (ms *MemoryStore) WriteLongTerm(content string) error { // Use unified atomic write utility with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - - if err := fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600); err != nil { - return err - } - + err := fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600) ms.InvalidateCache() - - return nil -} - -// ClearLongTerm removes the long-term memory file. - -func (ms *MemoryStore) ClearLongTerm() error { - if err := os.Remove(ms.memoryFile); err != nil && !os.IsNotExist(err) { - return err - } - - ms.InvalidateCache() - - return nil + return err } // ReadToday reads today's daily note. - // Returns empty string if the file doesn't exist. - func (ms *MemoryStore) ReadToday() string { todayFile := ms.getTodayFile() - if data, err := os.ReadFile(todayFile); err == nil { return string(data) } - return "" } // AppendToday appends content to today's daily note. - // If the file doesn't exist, it creates a new file with a date header. - func (ms *MemoryStore) AppendToday(content string) error { todayFile := ms.getTodayFile() // Ensure month directory exists - monthDir := filepath.Dir(todayFile) - if err := os.MkdirAll(monthDir, 0o755); err != nil { return err } var existingContent string - if data, err := os.ReadFile(todayFile); err == nil { existingContent = string(data) } var newContent string - if existingContent == "" { // Add header for new day - header := fmt.Sprintf("# %s\n\n", time.Now().Format("2006-01-02")) - newContent = header + content } else { // Append to existing content - newContent = existingContent + "\n" + content } // Use unified atomic write utility with explicit sync for flash storage reliability. - return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600) } // GetRecentDailyNotes returns daily notes from the last N days. - // Contents are joined with "---" separator. - func (ms *MemoryStore) GetRecentDailyNotes(days int) string { var sb strings.Builder - first := true for i := range days { date := time.Now().AddDate(0, 0, -i) - dateStr := date.Format("20060102") // YYYYMMDD - - monthDir := dateStr[:6] // YYYYMM - + monthDir := dateStr[:6] // YYYYMM filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md") if data, err := os.ReadFile(filePath); err == nil { if !first { sb.WriteString("\n\n---\n\n") } - sb.Write(data) - first = false } } @@ -431,862 +134,9 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string { return sb.String() } -// ---------- Plan state query methods ---------- - -var ( - reActivePlan = regexp.MustCompile(`(?m)^# Active Plan`) - - reStatus = regexp.MustCompile(`(?m)^> Status:\s*(.+)`) - - rePhase = regexp.MustCompile(`(?m)^> Phase:\s*(\d+)`) - - rePhaseHeader = regexp.MustCompile(`(?m)^## Phase (\d+):\s*(.*)`) - - reWorkDir = regexp.MustCompile(`(?m)^> WorkDir:\s*(.+)`) -) - -// HasActivePlan returns true if MEMORY.md contains an active plan. - -func (ms *MemoryStore) HasActivePlan() bool { - return ms.getParsedPlanState().hasActivePlan -} - -// GetPlanStatus returns the plan status: "interviewing", "executing", or "". - -func (ms *MemoryStore) GetPlanStatus() string { - return ms.getParsedPlanState().status -} - -// GetCurrentPhase returns the current phase number from "> Phase: N". - -func (ms *MemoryStore) GetCurrentPhase() int { - return ms.getParsedPlanState().currentPhase -} - -// GetTotalPhases returns the total number of phases (max ## Phase N). - -func (ms *MemoryStore) GetTotalPhases() int { - return ms.getParsedPlanState().totalPhases -} - -// IsPlanComplete returns true if all steps in all phases are [x]. - -func (ms *MemoryStore) IsPlanComplete() bool { - phases := ms.getParsedPlanState().phases - - if len(phases) == 0 { - return false - } - - hasSteps := false - - for _, p := range phases { - for _, s := range p.Steps { - hasSteps = true - - if !s.Done { - return false - } - } - } - - return hasSteps -} - -// IsCurrentPhaseComplete returns true if all steps in the current phase are [x]. - -func (ms *MemoryStore) IsCurrentPhaseComplete() bool { - state := ms.getParsedPlanState() - - if state.currentPhase == 0 { - return false - } - - for _, p := range state.phases { - if p.Number == state.currentPhase { - if len(p.Steps) == 0 { - return false - } - - for _, s := range p.Steps { - if !s.Done { - return false - } - } - - return true - } - } - - return false -} - -// extractPhaseContent returns the content of a specific phase section. - -func (ms *MemoryStore) extractPhaseContent(content string, phase int) string { - lines := strings.Split(content, "\n") - - inPhase := false - - var result []string - - phasePrefix := fmt.Sprintf("## Phase %d:", phase) - - for _, line := range lines { - if strings.HasPrefix(line, phasePrefix) { - inPhase = true - - continue - } - - if inPhase { - // Stop at next phase header or Context section - - if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") { - break - } - - result = append(result, line) - } - } - - return strings.Join(result, "\n") -} - -// PlanPhase represents a phase with its steps, for structured API output. - -type PlanPhase struct { - Number int `json:"number"` - - Title string `json:"title"` - - Steps []PlanStep `json:"steps"` -} - -// PlanStep represents a single step within a phase. - -type PlanStep struct { - Index int `json:"index"` // 1-based within the phase - - Description string `json:"description"` - - Done bool `json:"done"` -} - -// GetPlanPhases parses MEMORY.md and returns all phases with their steps. - -func (ms *MemoryStore) GetPlanPhases() []PlanPhase { - return clonePlanPhases(ms.getParsedPlanState().phases) -} - -func (ms *MemoryStore) getPlanPhasesFrom(content string) []PlanPhase { - if !reActivePlan.MatchString(content) { - return nil - } - - totalPhases := maxPhaseNumber(content) - - phases := make([]PlanPhase, 0, totalPhases) - - for p := 1; p <= totalPhases; p++ { - title := ms.getPhaseTitle(content, p) - - phaseContent := ms.extractPhaseContent(content, p) - - var steps []PlanStep - - stepIdx := 0 - - for _, line := range strings.Split(phaseContent, "\n") { - line = strings.TrimSpace(line) - - if strings.HasPrefix(line, "- [x] ") { - stepIdx++ - - steps = append(steps, PlanStep{ - Index: stepIdx, - - Description: line[6:], - - Done: true, - }) - } else if strings.HasPrefix(line, "- [ ] ") { - stepIdx++ - - steps = append(steps, PlanStep{ - Index: stepIdx, - - Description: line[6:], - - Done: false, - }) - } - } - - phases = append(phases, PlanPhase{ - Number: p, - - Title: title, - - Steps: steps, - }) - } - - return phases -} - -// ---------- Plan mutation methods ---------- - -// SetStatus sets the plan status (interviewing or executing). - -func (ms *MemoryStore) SetStatus(status string) error { - content := ms.ReadLongTerm() - - if m := reStatus.FindString(content); m != "" { - content = strings.Replace(content, m, "> Status: "+status, 1) - } - - return ms.WriteLongTerm(content) -} - -// AdvancePhase increments the current phase number by 1. - -func (ms *MemoryStore) AdvancePhase() error { - content := ms.ReadLongTerm() - - m := rePhase.FindStringSubmatch(content) - - if len(m) < 2 { - return fmt.Errorf("no phase marker found") - } - - current, _ := strconv.Atoi(m[1]) - - next := current + 1 - - content = strings.Replace(content, m[0], fmt.Sprintf("> Phase: %d", next), 1) - - return ms.WriteLongTerm(content) -} - -// SetPhase sets the current phase number to n. - -func (ms *MemoryStore) SetPhase(n int) error { - content := ms.ReadLongTerm() - - m := rePhase.FindString(content) - - if m == "" { - return fmt.Errorf("no phase marker found") - } - - content = strings.Replace(content, m, fmt.Sprintf("> Phase: %d", n), 1) - - return ms.WriteLongTerm(content) -} - -// MarkStep marks the nth step (1-based) in the given phase as done [x]. - -func (ms *MemoryStore) MarkStep(phase, step int) error { - content := ms.ReadLongTerm() - - lines := strings.Split(content, "\n") - - phasePrefix := fmt.Sprintf("## Phase %d:", phase) - - inPhase := false - - stepCount := 0 - - for i, line := range lines { - if strings.HasPrefix(line, phasePrefix) { - inPhase = true - - continue - } - - if inPhase { - if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") { - break - } - - if strings.HasPrefix(line, "- [ ] ") { - stepCount++ - - if stepCount == step { - lines[i] = strings.Replace(line, "- [ ] ", "- [x] ", 1) - - return ms.WriteLongTerm(strings.Join(lines, "\n")) - } - } - } - } - - return fmt.Errorf("step %d not found in phase %d", step, phase) -} - -// AddStep appends a new step to the given phase. - -func (ms *MemoryStore) AddStep(phase int, desc string) error { - content := ms.ReadLongTerm() - - lines := strings.Split(content, "\n") - - phasePrefix := fmt.Sprintf("## Phase %d:", phase) - - inPhase := false - - insertIdx := -1 - - for i, line := range lines { - if strings.HasPrefix(line, phasePrefix) { - inPhase = true - - continue - } - - if inPhase { - if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") { - insertIdx = i - - break - } - - // Track last step line - - if strings.HasPrefix(line, "- [") { - insertIdx = i + 1 - } - } - } - - if insertIdx < 0 { - // Phase not found or empty; append at end - - if inPhase { - insertIdx = len(lines) - } else { - return fmt.Errorf("phase %d not found", phase) - } - } - - newStep := "- [ ] " + desc - - newLines := make([]string, 0, len(lines)+1) - - newLines = append(newLines, lines[:insertIdx]...) - - newLines = append(newLines, newStep) - - newLines = append(newLines, lines[insertIdx:]...) - - return ms.WriteLongTerm(strings.Join(newLines, "\n")) -} - -// ValidatePlanStructure checks that the plan has valid structure for - -// transitioning out of the interview phase. Returns nil if valid, - -// or an error describing the first problem found. - -func (ms *MemoryStore) ValidatePlanStructure() error { - content := ms.ReadLongTerm() - - // 1. Header: # Active Plan must exist - - if !reActivePlan.MatchString(content) { - return fmt.Errorf("missing '# Active Plan' header") - } - - // 2. Required metadata lines - - if !reStatus.MatchString(content) { - return fmt.Errorf("missing '> Status:' line") - } - - if !rePhase.MatchString(content) { - return fmt.Errorf("missing '> Phase:' line") - } - - // 3. At least one phase header (## Phase N: title) - - phases := ms.getPlanPhasesFrom(content) - - if len(phases) == 0 { - return fmt.Errorf("no '## Phase N:' sections found") - } - - // 4. Every phase must have at least one checkbox step - - for _, p := range phases { - if len(p.Steps) == 0 { - return fmt.Errorf("Phase %d has no checkbox steps (use '- [ ] ...')", p.Number) - } - } - - return nil -} - -// ---------- Selective injection methods ---------- - -// GetPlanWorkDir returns the WorkDir from the plan metadata, or "". - -func (ms *MemoryStore) GetPlanWorkDir() string { - return ms.getParsedPlanState().workDir -} - -// reTaskLine extracts the task name from "> Task: <description>". - -var reTaskLine = regexp.MustCompile(`(?m)^> Task:\s*(.+)`) - -// GetPlanTaskName returns the task description from the plan metadata, or "". - -func (ms *MemoryStore) GetPlanTaskName() string { - return ms.getParsedPlanState().taskName -} - -// interviewSeed is the initial content written to MEMORY.md when /plan starts. - -const interviewSeedTemplate = `# Active Plan - - - -> Task: %s - -> WorkDir: %s - -> Status: interviewing - -> Phase: 1 - -` - -// BuildInterviewSeed creates the initial plan seed for a given task description. - -func BuildInterviewSeed(task, workDir string) string { - return fmt.Sprintf(interviewSeedTemplate, task, workDir) -} - -// GetInterviewContext returns context for injection during the interviewing phase. - -// Includes the full seed + interview guide + target format template. - -func (ms *MemoryStore) GetInterviewContext() string { - return ms.getInterviewContextFrom(ms.ReadLongTerm()) -} - -func (ms *MemoryStore) getInterviewContextFrom(content string) string { - var sb strings.Builder - - sb.WriteString("## Active Plan (interviewing)\n\n") - - sb.WriteString(content) - - sb.WriteString("\n\n### Interview Guide\n") - - sb.WriteString("Ask about:\n") - - sb.WriteString("- Goals and success criteria\n") - - sb.WriteString("- Constraints (time, budget, platform)\n") - - sb.WriteString("- Environment (OS, language, runtime versions)\n") - - sb.WriteString("- Tooling preferences (test framework, linter, formatter, CI)\n") - - sb.WriteString("- Key commands the user already runs (build, test, deploy)\n") - - sb.WriteString("\n### Rules\n") - - sb.WriteString( - "- NEVER remove or overwrite the header block (`# Active Plan`, `> Task:`, `> Status:`, `> Phase:` lines). The system parses these to track state.\n", - ) - - sb.WriteString( - - "- After each answer, use edit_file to append findings to the ## Context section of memory/MEMORY.md.\n", - ) - - sb.WriteString( - "- When you have enough information, use edit_file to add ## Phase, ## Commands, and ## Context sections BELOW the header block.\n", - ) - - sb.WriteString( - - "- Each step MUST use checkbox syntax: `- [ ] description`. The system parses checkboxes to track progress.\n", - ) - - sb.WriteString("- Organize into 2-5 phases with 3-5 steps each.\n") - - sb.WriteString( - "- After writing Phases, change `> Status: interviewing` to `> Status: review` via edit_file. The user must approve with /plan start before execution begins.\n", - ) - - sb.WriteString("\n### Target Format (MANDATORY — system parses this exact structure)\n") - - sb.WriteString("\n") - - sb.WriteString("# Active Plan\n") - - sb.WriteString("> Task: <description>\n") - - sb.WriteString("> WorkDir: <path>\n") - - sb.WriteString("> Status: interviewing\n") - - sb.WriteString("> Phase: 1\n") - - sb.WriteString("\n") - - sb.WriteString("## Phase 1: <title>\n") - - sb.WriteString("- [ ] Step description\n") - - sb.WriteString("- [ ] Step description\n") - - sb.WriteString("\n") - - sb.WriteString("## Phase 2: <title>\n") - - sb.WriteString("- [ ] Step description\n") - - sb.WriteString("- [ ] Step description\n") - - sb.WriteString("\n") - - sb.WriteString("## Commands\n") - - sb.WriteString("build: <project-specific build command>\n") - - sb.WriteString("test: <project-specific test command>\n") - - sb.WriteString("lint: <project-specific lint command>\n") - - sb.WriteString("\n") - - sb.WriteString("## Context\n") - - sb.WriteString("<collected requirements, decisions, environment>\n") - - return sb.String() -} - -// GetReviewContext returns context for injection during the review phase. - -// Shows the full plan and instructs the AI to wait for user approval. - -func (ms *MemoryStore) GetReviewContext() string { - return ms.getReviewContextFrom(ms.ReadLongTerm()) -} - -func (ms *MemoryStore) getReviewContextFrom(content string) string { - var sb strings.Builder - - sb.WriteString("## Active Plan (awaiting approval)\n\n") - - sb.WriteString(content) - - sb.WriteString("\n\nThe plan is awaiting user approval.\n") - - sb.WriteString("- If the user requests changes, update memory/MEMORY.md via edit_file.\n") - - sb.WriteString("- Do NOT change Status yourself. The user will run /plan start to approve.\n") - - return sb.String() -} - -// GetPlanContext returns context for injection during the executing phase. - -// Only the current phase is shown in detail; completed phases are compressed - -// to one-line summaries; future phases are omitted. - -func (ms *MemoryStore) GetPlanContext() string { - return ms.getPlanContextFrom(ms.ReadLongTerm()) -} - -func (ms *MemoryStore) getPlanContextFrom(content string) string { - var currentPhase int - - if m := rePhase.FindStringSubmatch(content); len(m) >= 2 { - currentPhase, _ = strconv.Atoi(m[1]) - } - - totalPhases := maxPhaseNumber(content) - - taskLine := "" - - if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 { - taskLine = strings.TrimSpace(m[1]) - } - - var sb strings.Builder - - sb.WriteString("## Active Plan\n") - - fmt.Fprintf(&sb, "Task: %s | Phase %d/%d\n", taskLine, currentPhase, totalPhases) - - // Completed phases: one-line summaries - - for p := 1; p < currentPhase; p++ { - title := ms.getPhaseTitle(content, p) - - fmt.Fprintf(&sb, "Done: Phase %d (%s)\n", p, title) - } - - // Current phase: full detail - - if currentPhase > 0 { - title := ms.getPhaseTitle(content, currentPhase) - - fmt.Fprintf(&sb, "### Current: Phase %d — %s\n", currentPhase, title) - - phaseContent := ms.extractPhaseContent(content, currentPhase) - - sb.WriteString(strings.TrimSpace(phaseContent)) - - sb.WriteString("\n") - } - - // Commands section: always included if present - - commandsContent := ms.extractCommandsSection(content) - - if commandsContent != "" { - sb.WriteString("### Commands\n") - - sb.WriteString(commandsContent) - - sb.WriteString("\n") - } - - // Context section: always included - - contextContent := ms.extractContextSection(content) - - if contextContent != "" { - sb.WriteString("### Context\n") - - sb.WriteString(contextContent) - - sb.WriteString("\n") - } - - // Orchestration section: conductor's delegation tracking (Delegated/Findings/Decisions) - - orchContent := ms.extractSection(content, "Orchestration") - - if orchContent != "" { - sb.WriteString("### Orchestration\n") - - sb.WriteString(orchContent) - - sb.WriteString("\n") - } - - return sb.String() -} - -// maxPhaseNumber returns the highest phase number found in content. - -func maxPhaseNumber(content string) int { - matches := rePhaseHeader.FindAllStringSubmatch(content, -1) - - maxN := 0 - - for _, m := range matches { - if len(m) >= 2 { - n, _ := strconv.Atoi(m[1]) - - if n > maxN { - maxN = n - } - } - } - - return maxN -} - -// getPhaseTitle extracts the title of a phase from "## Phase N: Title". - -func (ms *MemoryStore) getPhaseTitle(content string, phase int) string { - matches := rePhaseHeader.FindAllStringSubmatch(content, -1) - - for _, m := range matches { - if len(m) >= 3 { - n, _ := strconv.Atoi(m[1]) - - if n == phase { - return strings.TrimSpace(m[2]) - } - } - } - - return "" -} - -// extractSection extracts a named ## section from the plan content. - -// It returns everything between "## <name>" and the next "## " header. - -func (ms *MemoryStore) extractSection(content, name string) string { - lines := strings.Split(content, "\n") - - prefix := "## " + name - - inSection := false - - var result []string - - for _, line := range lines { - if strings.HasPrefix(line, prefix) { - inSection = true - - continue - } - - if inSection { - if strings.HasPrefix(line, "## ") { - break - } - - result = append(result, line) - } - } - - return strings.TrimSpace(strings.Join(result, "\n")) -} - -// extractContextSection extracts the ## Context section from the plan. - -func (ms *MemoryStore) extractContextSection(content string) string { - return ms.extractSection(content, "Context") -} - -// extractCommandsSection extracts the ## Commands section from the plan. - -func (ms *MemoryStore) extractCommandsSection(content string) string { - return ms.extractSection(content, "Commands") -} - -// FormatPlanDisplay returns a user-facing display of the full plan with emoji indicators. - -func (ms *MemoryStore) FormatPlanDisplay() string { - state := ms.getParsedPlanState() - - if !state.hasActivePlan { - return "No active plan." - } - - var sb strings.Builder - - sb.WriteString(fmt.Sprintf("Plan: %s\n", state.taskName)) - - sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", state.status, state.currentPhase, len(state.phases))) - - for _, p := range state.phases { - // Determine phase emoji - - var emoji string - - if p.Number < state.currentPhase { - emoji = "\u2705" // checkmark - } else if p.Number == state.currentPhase { - emoji = "\u25B6\uFE0F" // play button - } else { - emoji = "\u23F3" // hourglass - } - - sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p.Number, p.Title)) - - // Show steps for current and completed phases - - if p.Number <= state.currentPhase { - for _, s := range p.Steps { - if s.Done { - sb.WriteString(" \u2611 " + s.Description + "\n") - } else { - sb.WriteString(" \u2610 " + s.Description + "\n") - } - } - } - } - - commandsContent := ms.extractCommandsSection(state.content) - - if commandsContent != "" { - sb.WriteString("\nCommands:\n") - - for _, line := range strings.Split(commandsContent, "\n") { - line = strings.TrimSpace(line) - - if line != "" { - sb.WriteString(" " + line + "\n") - } - } - } - - contextContent := ms.extractContextSection(state.content) - - if contextContent != "" { - sb.WriteString("\nContext: " + contextContent + "\n") - } - - return sb.String() -} - -// ---------- GetMemoryContext (plan-aware) ---------- - // GetMemoryContext returns formatted memory context for the agent prompt. - -// When an active plan exists, it uses selective injection based on plan status. - -// During interviewing: full seed + interview guide. - -// During executing: current phase only with compressed completed phases. - -// When plan is active, daily notes injection is suppressed to save context. - +// Includes long-term memory and recent daily notes. +// Plan-aware: selectively injects context based on plan status. func (ms *MemoryStore) GetMemoryContext() string { - var parts []string - - state := ms.getParsedPlanState() - - longTerm := state.content - - if longTerm != "" { - if state.hasActivePlan { - switch state.status { - case "interviewing": - - parts = append(parts, ms.getInterviewContextFrom(longTerm)) - - case "review": - - parts = append(parts, ms.getReviewContextFrom(longTerm)) - - default: - - parts = append(parts, ms.getPlanContextFrom(longTerm)) - } - } else { - parts = append(parts, "## Long-term Memory\n\n"+longTerm) - } - } - - // Suppress daily notes when a plan is active to save context - - if !state.hasActivePlan { - recentNotes := ms.GetRecentDailyNotes(3) - - if recentNotes != "" { - parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes) - } - } - - if len(parts) == 0 { - return "" - } - - return strings.Join(parts, "\n\n---\n\n") + return ms.getMemoryContextPlanAware() } diff --git a/pkg/agent/memory_ext.go b/pkg/agent/memory_ext.go new file mode 100644 index 000000000..1e595c09b --- /dev/null +++ b/pkg/agent/memory_ext.go @@ -0,0 +1,728 @@ +package agent + +import ( + "fmt" + "os" + "regexp" + "strconv" + "strings" + "time" +) + +// Cache types for MemoryStore. + +type longTermFileCache struct { + loaded bool + exists bool + modTime time.Time + size int64 + content string +} + +type parsedPlanStateCache struct { + loaded bool + sourceContent string + state parsedPlanState +} + +type parsedPlanState struct { + content string + hasActivePlan bool + status string + currentPhase int + totalPhases int + workDir string + taskName string + phases []PlanPhase +} + +// InvalidateCache clears all in-memory caches for MEMORY.md content and parsed plan state. +func (ms *MemoryStore) InvalidateCache() { + ms.cacheMu.Lock() + defer ms.cacheMu.Unlock() + ms.longTermCache = longTermFileCache{} + ms.parsedPlanCache = parsedPlanStateCache{} +} + +func (ms *MemoryStore) readLongTermCached() string { + info, err := os.Stat(ms.memoryFile) + if err != nil { + if !os.IsNotExist(err) { + return "" + } + ms.cacheMu.RLock() + cachedMissing := ms.longTermCache.loaded && !ms.longTermCache.exists + ms.cacheMu.RUnlock() + if cachedMissing { + return "" + } + ms.cacheMu.Lock() + ms.longTermCache = longTermFileCache{loaded: true, exists: false} + ms.parsedPlanCache = parsedPlanStateCache{} + ms.cacheMu.Unlock() + return "" + } + + modTime := info.ModTime() + size := info.Size() + + ms.cacheMu.RLock() + if ms.longTermCache.loaded && + ms.longTermCache.exists && + ms.longTermCache.modTime.Equal(modTime) && + ms.longTermCache.size == size { + content := ms.longTermCache.content + ms.cacheMu.RUnlock() + return content + } + ms.cacheMu.RUnlock() + + data, err := os.ReadFile(ms.memoryFile) + if err != nil { + if os.IsNotExist(err) { + ms.cacheMu.Lock() + ms.longTermCache = longTermFileCache{loaded: true, exists: false} + ms.parsedPlanCache = parsedPlanStateCache{} + ms.cacheMu.Unlock() + } + return "" + } + + content := string(data) + ms.cacheMu.Lock() + ms.longTermCache = longTermFileCache{ + loaded: true, + exists: true, + modTime: modTime, + size: size, + content: content, + } + if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent != content { + ms.parsedPlanCache = parsedPlanStateCache{} + } + ms.cacheMu.Unlock() + return content +} + +func (ms *MemoryStore) getParsedPlanState() parsedPlanState { + content := ms.ReadLongTerm() + + ms.cacheMu.RLock() + if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent == content { + state := ms.parsedPlanCache.state + ms.cacheMu.RUnlock() + return state + } + ms.cacheMu.RUnlock() + + state := ms.parsePlanState(content) + + ms.cacheMu.Lock() + if !ms.parsedPlanCache.loaded || ms.parsedPlanCache.sourceContent != content { + ms.parsedPlanCache = parsedPlanStateCache{ + loaded: true, + sourceContent: content, + state: state, + } + } else { + state = ms.parsedPlanCache.state + } + ms.cacheMu.Unlock() + return state +} + +func (ms *MemoryStore) parsePlanState(content string) parsedPlanState { + state := parsedPlanState{content: content} + if content == "" || !reActivePlan.MatchString(content) { + return state + } + state.hasActivePlan = true + if m := reStatus.FindStringSubmatch(content); len(m) >= 2 { + state.status = strings.TrimSpace(m[1]) + } + if m := rePhase.FindStringSubmatch(content); len(m) >= 2 { + state.currentPhase, _ = strconv.Atoi(m[1]) + } + state.totalPhases = maxPhaseNumber(content) + if m := reWorkDir.FindStringSubmatch(content); len(m) >= 2 { + state.workDir = strings.TrimSpace(m[1]) + } + if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 { + state.taskName = strings.TrimSpace(m[1]) + } + state.phases = ms.getPlanPhasesFrom(content) + return state +} + +func clonePlanPhases(phases []PlanPhase) []PlanPhase { + if len(phases) == 0 { + return nil + } + result := make([]PlanPhase, 0, len(phases)) + for _, p := range phases { + phase := PlanPhase{Number: p.Number, Title: p.Title} + if len(p.Steps) > 0 { + phase.Steps = append([]PlanStep(nil), p.Steps...) + } + result = append(result, phase) + } + return result +} + +// ClearLongTerm removes the long-term memory file. +func (ms *MemoryStore) ClearLongTerm() error { + if err := os.Remove(ms.memoryFile); err != nil && !os.IsNotExist(err) { + return err + } + ms.InvalidateCache() + return nil +} + +// ---------- Plan state query methods ---------- + +var ( + reActivePlan = regexp.MustCompile(`(?m)^# Active Plan`) + reStatus = regexp.MustCompile(`(?m)^> Status:\s*(.+)`) + rePhase = regexp.MustCompile(`(?m)^> Phase:\s*(\d+)`) + rePhaseHeader = regexp.MustCompile(`(?m)^## Phase (\d+):\s*(.*)`) + reWorkDir = regexp.MustCompile(`(?m)^> WorkDir:\s*(.+)`) + reTaskLine = regexp.MustCompile(`(?m)^> Task:\s*(.+)`) +) + +// HasActivePlan returns true if MEMORY.md contains an active plan. +func (ms *MemoryStore) HasActivePlan() bool { + return ms.getParsedPlanState().hasActivePlan +} + +// GetPlanStatus returns the plan status: "interviewing", "executing", or "". +func (ms *MemoryStore) GetPlanStatus() string { + return ms.getParsedPlanState().status +} + +// GetCurrentPhase returns the current phase number from "> Phase: N". +func (ms *MemoryStore) GetCurrentPhase() int { + return ms.getParsedPlanState().currentPhase +} + +// GetTotalPhases returns the total number of phases (max ## Phase N). +func (ms *MemoryStore) GetTotalPhases() int { + return ms.getParsedPlanState().totalPhases +} + +// IsPlanComplete returns true if all steps in all phases are [x]. +func (ms *MemoryStore) IsPlanComplete() bool { + phases := ms.getParsedPlanState().phases + if len(phases) == 0 { + return false + } + hasSteps := false + for _, p := range phases { + for _, s := range p.Steps { + hasSteps = true + if !s.Done { + return false + } + } + } + return hasSteps +} + +// IsCurrentPhaseComplete returns true if all steps in the current phase are [x]. +func (ms *MemoryStore) IsCurrentPhaseComplete() bool { + state := ms.getParsedPlanState() + if state.currentPhase == 0 { + return false + } + for _, p := range state.phases { + if p.Number == state.currentPhase { + if len(p.Steps) == 0 { + return false + } + for _, s := range p.Steps { + if !s.Done { + return false + } + } + return true + } + } + return false +} + +func (ms *MemoryStore) extractPhaseContent(content string, phase int) string { + lines := strings.Split(content, "\n") + inPhase := false + var result []string + phasePrefix := fmt.Sprintf("## Phase %d:", phase) + for _, line := range lines { + if strings.HasPrefix(line, phasePrefix) { + inPhase = true + continue + } + if inPhase { + if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") { + break + } + result = append(result, line) + } + } + return strings.Join(result, "\n") +} + +// PlanPhase represents a phase with its steps, for structured API output. +type PlanPhase struct { + Number int `json:"number"` + Title string `json:"title"` + Steps []PlanStep `json:"steps"` +} + +// PlanStep represents a single step within a phase. +type PlanStep struct { + Index int `json:"index"` // 1-based within the phase + Description string `json:"description"` + Done bool `json:"done"` +} + +// GetPlanPhases parses MEMORY.md and returns all phases with their steps. +func (ms *MemoryStore) GetPlanPhases() []PlanPhase { + return clonePlanPhases(ms.getParsedPlanState().phases) +} + +func (ms *MemoryStore) getPlanPhasesFrom(content string) []PlanPhase { + if !reActivePlan.MatchString(content) { + return nil + } + totalPhases := maxPhaseNumber(content) + phases := make([]PlanPhase, 0, totalPhases) + for p := 1; p <= totalPhases; p++ { + title := ms.getPhaseTitle(content, p) + phaseContent := ms.extractPhaseContent(content, p) + var steps []PlanStep + stepIdx := 0 + for _, line := range strings.Split(phaseContent, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "- [x] ") { + stepIdx++ + steps = append(steps, PlanStep{Index: stepIdx, Description: line[6:], Done: true}) + } else if strings.HasPrefix(line, "- [ ] ") { + stepIdx++ + steps = append(steps, PlanStep{Index: stepIdx, Description: line[6:], Done: false}) + } + } + phases = append(phases, PlanPhase{Number: p, Title: title, Steps: steps}) + } + return phases +} + +// ---------- Plan mutation methods ---------- + +// SetStatus sets the plan status (interviewing or executing). +func (ms *MemoryStore) SetStatus(status string) error { + content := ms.ReadLongTerm() + if m := reStatus.FindString(content); m != "" { + content = strings.Replace(content, m, "> Status: "+status, 1) + } + return ms.WriteLongTerm(content) +} + +// AdvancePhase increments the current phase number by 1. +func (ms *MemoryStore) AdvancePhase() error { + content := ms.ReadLongTerm() + m := rePhase.FindStringSubmatch(content) + if len(m) < 2 { + return fmt.Errorf("no phase marker found") + } + current, _ := strconv.Atoi(m[1]) + next := current + 1 + content = strings.Replace(content, m[0], fmt.Sprintf("> Phase: %d", next), 1) + return ms.WriteLongTerm(content) +} + +// SetPhase sets the current phase number to n. +func (ms *MemoryStore) SetPhase(n int) error { + content := ms.ReadLongTerm() + m := rePhase.FindString(content) + if m == "" { + return fmt.Errorf("no phase marker found") + } + content = strings.Replace(content, m, fmt.Sprintf("> Phase: %d", n), 1) + return ms.WriteLongTerm(content) +} + +// MarkStep marks the nth step (1-based) in the given phase as done [x]. +func (ms *MemoryStore) MarkStep(phase, step int) error { + content := ms.ReadLongTerm() + lines := strings.Split(content, "\n") + phasePrefix := fmt.Sprintf("## Phase %d:", phase) + inPhase := false + stepCount := 0 + for i, line := range lines { + if strings.HasPrefix(line, phasePrefix) { + inPhase = true + continue + } + if inPhase { + if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") { + break + } + if strings.HasPrefix(line, "- [ ] ") { + stepCount++ + if stepCount == step { + lines[i] = strings.Replace(line, "- [ ] ", "- [x] ", 1) + return ms.WriteLongTerm(strings.Join(lines, "\n")) + } + } + } + } + return fmt.Errorf("step %d not found in phase %d", step, phase) +} + +// AddStep appends a new step to the given phase. +func (ms *MemoryStore) AddStep(phase int, desc string) error { + content := ms.ReadLongTerm() + lines := strings.Split(content, "\n") + phasePrefix := fmt.Sprintf("## Phase %d:", phase) + inPhase := false + insertIdx := -1 + for i, line := range lines { + if strings.HasPrefix(line, phasePrefix) { + inPhase = true + continue + } + if inPhase { + if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") { + insertIdx = i + break + } + if strings.HasPrefix(line, "- [") { + insertIdx = i + 1 + } + } + } + if insertIdx < 0 { + if inPhase { + insertIdx = len(lines) + } else { + return fmt.Errorf("phase %d not found", phase) + } + } + newStep := "- [ ] " + desc + newLines := make([]string, 0, len(lines)+1) + newLines = append(newLines, lines[:insertIdx]...) + newLines = append(newLines, newStep) + newLines = append(newLines, lines[insertIdx:]...) + return ms.WriteLongTerm(strings.Join(newLines, "\n")) +} + +// ValidatePlanStructure checks that the plan has valid structure for +// transitioning out of the interview phase. +func (ms *MemoryStore) ValidatePlanStructure() error { + content := ms.ReadLongTerm() + if !reActivePlan.MatchString(content) { + return fmt.Errorf("missing '# Active Plan' header") + } + if !reStatus.MatchString(content) { + return fmt.Errorf("missing '> Status:' line") + } + if !rePhase.MatchString(content) { + return fmt.Errorf("missing '> Phase:' line") + } + phases := ms.getPlanPhasesFrom(content) + if len(phases) == 0 { + return fmt.Errorf("no '## Phase N:' sections found") + } + for _, p := range phases { + if len(p.Steps) == 0 { + return fmt.Errorf("Phase %d has no checkbox steps (use '- [ ] ...')", p.Number) + } + } + return nil +} + +// ---------- Selective injection methods ---------- + +// GetPlanWorkDir returns the WorkDir from the plan metadata, or "". +func (ms *MemoryStore) GetPlanWorkDir() string { + return ms.getParsedPlanState().workDir +} + +// GetPlanTaskName returns the task description from the plan metadata, or "". +func (ms *MemoryStore) GetPlanTaskName() string { + return ms.getParsedPlanState().taskName +} + +const interviewSeedTemplate = `# Active Plan + +> Task: %s +> WorkDir: %s +> Status: interviewing +> Phase: 1 +` + +// BuildInterviewSeed creates the initial plan seed for a given task description. +func BuildInterviewSeed(task, workDir string) string { + return fmt.Sprintf(interviewSeedTemplate, task, workDir) +} + +// GetInterviewContext returns context for injection during the interviewing phase. +func (ms *MemoryStore) GetInterviewContext() string { + return ms.getInterviewContextFrom(ms.ReadLongTerm()) +} + +func (ms *MemoryStore) getInterviewContextFrom(content string) string { + var sb strings.Builder + sb.WriteString("## Active Plan (interviewing)\n\n") + sb.WriteString(content) + sb.WriteString("\n\n### Interview Guide\n") + sb.WriteString("Ask about:\n") + sb.WriteString("- Goals and success criteria\n") + sb.WriteString("- Constraints (time, budget, platform)\n") + sb.WriteString("- Environment (OS, language, runtime versions)\n") + sb.WriteString("- Tooling preferences (test framework, linter, formatter, CI)\n") + sb.WriteString("- Key commands the user already runs (build, test, deploy)\n") + sb.WriteString("\n### Rules\n") + sb.WriteString( + "- NEVER remove or overwrite the header block (`# Active Plan`, `> Task:`, `> Status:`, `> Phase:` lines). The system parses these to track state.\n", + ) + sb.WriteString( + "- After each answer, use edit_file to append findings to the ## Context section of memory/MEMORY.md.\n", + ) + sb.WriteString( + "- When you have enough information, use edit_file to add ## Phase, ## Commands, and ## Context sections BELOW the header block.\n", + ) + sb.WriteString( + "- Each step MUST use checkbox syntax: `- [ ] description`. The system parses checkboxes to track progress.\n", + ) + sb.WriteString("- Organize into 2-5 phases with 3-5 steps each.\n") + sb.WriteString( + "- After writing Phases, change `> Status: interviewing` to `> Status: review` via edit_file. The user must approve with /plan start before execution begins.\n", + ) + sb.WriteString("\n### Target Format (MANDATORY — system parses this exact structure)\n\n") + sb.WriteString("# Active Plan\n") + sb.WriteString("> Task: <description>\n") + sb.WriteString("> WorkDir: <path>\n") + sb.WriteString("> Status: interviewing\n") + sb.WriteString("> Phase: 1\n\n") + sb.WriteString("## Phase 1: <title>\n") + sb.WriteString("- [ ] Step description\n") + sb.WriteString("- [ ] Step description\n\n") + sb.WriteString("## Phase 2: <title>\n") + sb.WriteString("- [ ] Step description\n") + sb.WriteString("- [ ] Step description\n\n") + sb.WriteString("## Commands\n") + sb.WriteString("build: <project-specific build command>\n") + sb.WriteString("test: <project-specific test command>\n") + sb.WriteString("lint: <project-specific lint command>\n\n") + sb.WriteString("## Context\n") + sb.WriteString("<collected requirements, decisions, environment>\n") + return sb.String() +} + +// GetReviewContext returns context for injection during the review phase. +func (ms *MemoryStore) GetReviewContext() string { + return ms.getReviewContextFrom(ms.ReadLongTerm()) +} + +func (ms *MemoryStore) getReviewContextFrom(content string) string { + var sb strings.Builder + sb.WriteString("## Active Plan (awaiting approval)\n\n") + sb.WriteString(content) + sb.WriteString("\n\nThe plan is awaiting user approval.\n") + sb.WriteString("- If the user requests changes, update memory/MEMORY.md via edit_file.\n") + sb.WriteString("- Do NOT change Status yourself. The user will run /plan start to approve.\n") + return sb.String() +} + +// GetPlanContext returns context for injection during the executing phase. +func (ms *MemoryStore) GetPlanContext() string { + return ms.getPlanContextFrom(ms.ReadLongTerm()) +} + +func (ms *MemoryStore) getPlanContextFrom(content string) string { + var currentPhase int + if m := rePhase.FindStringSubmatch(content); len(m) >= 2 { + currentPhase, _ = strconv.Atoi(m[1]) + } + totalPhases := maxPhaseNumber(content) + taskLine := "" + if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 { + taskLine = strings.TrimSpace(m[1]) + } + + var sb strings.Builder + sb.WriteString("## Active Plan\n") + fmt.Fprintf(&sb, "Task: %s | Phase %d/%d\n", taskLine, currentPhase, totalPhases) + + for p := 1; p < currentPhase; p++ { + title := ms.getPhaseTitle(content, p) + fmt.Fprintf(&sb, "Done: Phase %d (%s)\n", p, title) + } + + if currentPhase > 0 { + title := ms.getPhaseTitle(content, currentPhase) + fmt.Fprintf(&sb, "### Current: Phase %d — %s\n", currentPhase, title) + phaseContent := ms.extractPhaseContent(content, currentPhase) + sb.WriteString(strings.TrimSpace(phaseContent)) + sb.WriteString("\n") + } + + if commandsContent := ms.extractCommandsSection(content); commandsContent != "" { + sb.WriteString("### Commands\n") + sb.WriteString(commandsContent) + sb.WriteString("\n") + } + + if contextContent := ms.extractContextSection(content); contextContent != "" { + sb.WriteString("### Context\n") + sb.WriteString(contextContent) + sb.WriteString("\n") + } + + if orchContent := ms.extractSection(content, "Orchestration"); orchContent != "" { + sb.WriteString("### Orchestration\n") + sb.WriteString(orchContent) + sb.WriteString("\n") + } + + return sb.String() +} + +func maxPhaseNumber(content string) int { + matches := rePhaseHeader.FindAllStringSubmatch(content, -1) + maxN := 0 + for _, m := range matches { + if len(m) >= 2 { + n, _ := strconv.Atoi(m[1]) + if n > maxN { + maxN = n + } + } + } + return maxN +} + +func (ms *MemoryStore) getPhaseTitle(content string, phase int) string { + matches := rePhaseHeader.FindAllStringSubmatch(content, -1) + for _, m := range matches { + if len(m) >= 3 { + n, _ := strconv.Atoi(m[1]) + if n == phase { + return strings.TrimSpace(m[2]) + } + } + } + return "" +} + +func (ms *MemoryStore) extractSection(content, name string) string { + lines := strings.Split(content, "\n") + prefix := "## " + name + inSection := false + var result []string + for _, line := range lines { + if strings.HasPrefix(line, prefix) { + inSection = true + continue + } + if inSection { + if strings.HasPrefix(line, "## ") { + break + } + result = append(result, line) + } + } + return strings.TrimSpace(strings.Join(result, "\n")) +} + +func (ms *MemoryStore) extractContextSection(content string) string { + return ms.extractSection(content, "Context") +} + +func (ms *MemoryStore) extractCommandsSection(content string) string { + return ms.extractSection(content, "Commands") +} + +// FormatPlanDisplay returns a user-facing display of the full plan with emoji indicators. +func (ms *MemoryStore) FormatPlanDisplay() string { + state := ms.getParsedPlanState() + if !state.hasActivePlan { + return "No active plan." + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("Plan: %s\n", state.taskName)) + sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", state.status, state.currentPhase, len(state.phases))) + + for _, p := range state.phases { + var emoji string + if p.Number < state.currentPhase { + emoji = "\u2705" + } else if p.Number == state.currentPhase { + emoji = "\u25B6\uFE0F" + } else { + emoji = "\u23F3" + } + sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p.Number, p.Title)) + if p.Number <= state.currentPhase { + for _, s := range p.Steps { + if s.Done { + sb.WriteString(" \u2611 " + s.Description + "\n") + } else { + sb.WriteString(" \u2610 " + s.Description + "\n") + } + } + } + } + + if commandsContent := ms.extractCommandsSection(state.content); commandsContent != "" { + sb.WriteString("\nCommands:\n") + for _, line := range strings.Split(commandsContent, "\n") { + line = strings.TrimSpace(line) + if line != "" { + sb.WriteString(" " + line + "\n") + } + } + } + + if contextContent := ms.extractContextSection(state.content); contextContent != "" { + sb.WriteString("\nContext: " + contextContent + "\n") + } + + return sb.String() +} + +// ---------- GetMemoryContext (plan-aware) ---------- + +func (ms *MemoryStore) getMemoryContextPlanAware() string { + var parts []string + state := ms.getParsedPlanState() + longTerm := state.content + + if longTerm != "" { + if state.hasActivePlan { + switch state.status { + case "interviewing": + parts = append(parts, ms.getInterviewContextFrom(longTerm)) + case "review": + parts = append(parts, ms.getReviewContextFrom(longTerm)) + default: + parts = append(parts, ms.getPlanContextFrom(longTerm)) + } + } else { + parts = append(parts, "## Long-term Memory\n\n"+longTerm) + } + } + + // Suppress daily notes when a plan is active to save context + if !state.hasActivePlan { + recentNotes := ms.GetRecentDailyNotes(3) + if recentNotes != "" { + parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes) + } + } + + if len(parts) == 0 { + return "" + } + return strings.Join(parts, "\n\n---\n\n") +} diff --git a/pkg/agent/mock_provider_test.go b/pkg/agent/mock_provider_test.go index f4042fd01..4962810dc 100644 --- a/pkg/agent/mock_provider_test.go +++ b/pkg/agent/mock_provider_test.go @@ -10,18 +10,13 @@ type mockProvider struct{} func (m *mockProvider) Chat( ctx context.Context, - messages []providers.Message, - tools []providers.ToolDefinition, - model string, - opts map[string]any, ) (*providers.LLMResponse, error) { return &providers.LLMResponse{ - Content: "Mock response", - + Content: "Mock response", ToolCalls: []providers.ToolCall{}, }, nil } diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 300352331..58b7ce440 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -11,62 +11,43 @@ import ( ) // AgentRegistry manages multiple agent instances and routes messages to them. - type AgentRegistry struct { - agents map[string]*AgentInstance - + agents map[string]*AgentInstance resolver *routing.RouteResolver - - mu sync.RWMutex + mu sync.RWMutex } // NewAgentRegistry creates a registry from config, instantiating all agents. - func NewAgentRegistry( cfg *config.Config, - provider providers.LLMProvider, ) *AgentRegistry { registry := &AgentRegistry{ - agents: make(map[string]*AgentInstance), - + agents: make(map[string]*AgentInstance), resolver: routing.NewRouteResolver(cfg), } agentConfigs := cfg.Agents.List - if len(agentConfigs) == 0 { implicitAgent := &config.AgentConfig{ - ID: "main", - + ID: "main", Default: true, } - instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider) - registry.agents["main"] = instance - logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil) } else { for i := range agentConfigs { ac := &agentConfigs[i] - id := routing.NormalizeAgentID(ac.ID) - instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider) - registry.agents[id] = instance - logger.InfoCF("agent", "Registered agent", - map[string]any{ - "agent_id": id, - - "name": ac.Name, - + "agent_id": id, + "name": ac.Name, "workspace": instance.Workspace, - - "model": instance.Model, + "model": instance.Model, }) } } @@ -75,66 +56,48 @@ func NewAgentRegistry( } // GetAgent returns the agent instance for a given ID. - func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) { r.mu.RLock() - defer r.mu.RUnlock() - id := routing.NormalizeAgentID(agentID) - agent, ok := r.agents[id] - return agent, ok } // ResolveRoute determines which agent handles the message. - func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute { return r.resolver.ResolveRoute(input) } // ListAgentIDs returns all registered agent IDs. - func (r *AgentRegistry) ListAgentIDs() []string { r.mu.RLock() - defer r.mu.RUnlock() - ids := make([]string, 0, len(r.agents)) - for id := range r.agents { ids = append(ids, id) } - return ids } // CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID. - func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool { parent, ok := r.GetAgent(parentAgentID) - if !ok { return false } - if parent.Subagents == nil || parent.Subagents.AllowAgents == nil { return false } - targetNorm := routing.NormalizeAgentID(targetAgentID) - for _, allowed := range parent.Subagents.AllowAgents { if allowed == "*" { return true } - if routing.NormalizeAgentID(allowed) == targetNorm { return true } } - return false } @@ -151,20 +114,27 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) { } } -// GetDefaultAgent returns the default agent instance. +// Close releases resources held by all registered agents. +func (r *AgentRegistry) Close() { + r.mu.RLock() + defer r.mu.RUnlock() + for _, agent := range r.agents { + if err := agent.Close(); err != nil { + logger.WarnCF("agent", "Failed to close agent", + map[string]any{"agent_id": agent.ID, "error": err.Error()}) + } + } +} +// GetDefaultAgent returns the default agent instance. func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { r.mu.RLock() - defer r.mu.RUnlock() - if agent, ok := r.agents["main"]; ok { return agent } - for _, agent := range r.agents { return agent } - return nil } diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go index 5a53f92e6..518bb441f 100644 --- a/pkg/agent/registry_test.go +++ b/pkg/agent/registry_test.go @@ -12,13 +12,9 @@ type mockRegistryProvider struct{} func (m *mockRegistryProvider) Chat( ctx context.Context, - messages []providers.Message, - tools []providers.ToolDefinition, - model string, - options map[string]any, ) (*providers.LLMResponse, error) { return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil @@ -32,15 +28,11 @@ func testCfg(agents []config.AgentConfig) *config.Config { return &config.Config{ Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ - Workspace: "/tmp/picoclaw-test-registry", - - Model: "gpt-4", - - MaxTokens: 8192, - + Workspace: "/tmp/picoclaw-test-registry", + Model: "gpt-4", + MaxTokens: 8192, MaxToolIterations: 10, }, - List: agents, }, } @@ -48,21 +40,17 @@ func testCfg(agents []config.AgentConfig) *config.Config { func TestNewAgentRegistry_ImplicitMain(t *testing.T) { cfg := testCfg(nil) - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) ids := registry.ListAgentIDs() - if len(ids) != 1 || ids[0] != "main" { t.Errorf("expected implicit main agent, got %v", ids) } agent, ok := registry.GetAgent("main") - if !ok || agent == nil { t.Fatal("expected to find 'main' agent") } - if agent.ID != "main" { t.Errorf("agent.ID = %q, want 'main'", agent.ID) } @@ -71,30 +59,24 @@ func TestNewAgentRegistry_ImplicitMain(t *testing.T) { func TestNewAgentRegistry_ExplicitAgents(t *testing.T) { cfg := testCfg([]config.AgentConfig{ {ID: "sales", Default: true, Name: "Sales Bot"}, - {ID: "support", Name: "Support Bot"}, }) - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) ids := registry.ListAgentIDs() - if len(ids) != 2 { t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids) } sales, ok := registry.GetAgent("sales") - if !ok || sales == nil { t.Fatal("expected to find 'sales' agent") } - if sales.Name != "Sales Bot" { t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name) } support, ok := registry.GetAgent("support") - if !ok || support == nil { t.Fatal("expected to find 'support' agent") } @@ -104,15 +86,12 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) { cfg := testCfg([]config.AgentConfig{ {ID: "my-agent", Default: true}, }) - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) agent, ok := registry.GetAgent("My-Agent") - if !ok || agent == nil { t.Fatal("expected to find agent with normalized ID") } - if agent.ID != "my-agent" { t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID) } @@ -121,16 +100,12 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) { func TestAgentRegistry_GetDefaultAgent(t *testing.T) { cfg := testCfg([]config.AgentConfig{ {ID: "alpha"}, - {ID: "beta", Default: true}, }) - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) // GetDefaultAgent first checks for "main", then returns any - agent := registry.GetDefaultAgent() - if agent == nil { t.Fatal("expected a default agent") } @@ -139,36 +114,27 @@ func TestAgentRegistry_GetDefaultAgent(t *testing.T) { func TestAgentRegistry_CanSpawnSubagent(t *testing.T) { cfg := testCfg([]config.AgentConfig{ { - ID: "parent", - + ID: "parent", Default: true, - Subagents: &config.SubagentsConfig{ AllowAgents: []string{"child1", "child2"}, }, }, - {ID: "child1"}, - {ID: "child2"}, - {ID: "restricted"}, }) - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) if !registry.CanSpawnSubagent("parent", "child1") { t.Error("expected parent to be allowed to spawn child1") } - if !registry.CanSpawnSubagent("parent", "child2") { t.Error("expected parent to be allowed to spawn child2") } - if registry.CanSpawnSubagent("parent", "restricted") { t.Error("expected parent to NOT be allowed to spawn restricted") } - if registry.CanSpawnSubagent("child1", "child2") { t.Error("expected child1 to NOT be allowed to spawn (no subagents config)") } @@ -177,24 +143,19 @@ func TestAgentRegistry_CanSpawnSubagent(t *testing.T) { func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) { cfg := testCfg([]config.AgentConfig{ { - ID: "admin", - + ID: "admin", Default: true, - Subagents: &config.SubagentsConfig{ AllowAgents: []string{"*"}, }, }, - {ID: "any-agent"}, }) - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) if !registry.CanSpawnSubagent("admin", "any-agent") { t.Error("expected wildcard to allow spawning any agent") } - if !registry.CanSpawnSubagent("admin", "nonexistent") { t.Error("expected wildcard to allow spawning even nonexistent agents") } @@ -202,15 +163,12 @@ func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) { func TestAgentInstance_Model(t *testing.T) { model := &config.AgentModelConfig{Primary: "claude-opus"} - cfg := testCfg([]config.AgentConfig{ {ID: "custom", Default: true, Model: model}, }) - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) agent, _ := registry.GetAgent("custom") - if agent.Model != "claude-opus" { t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model) } @@ -220,13 +178,10 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) { cfg := testCfg([]config.AgentConfig{ {ID: "inherit", Default: true}, }) - cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"} - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) agent, _ := registry.GetAgent("inherit") - if len(agent.Fallbacks) != 2 { t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks)) } @@ -234,22 +189,16 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) { func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) { model := &config.AgentModelConfig{ - Primary: "gpt-4", - + Primary: "gpt-4", Fallbacks: []string{}, // explicitly empty = disable - } - cfg := testCfg([]config.AgentConfig{ {ID: "no-fallback", Default: true, Model: model}, }) - cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"} - registry := NewAgentRegistry(cfg, &mockRegistryProvider{}) agent, _ := registry.GetAgent("no-fallback") - if len(agent.Fallbacks) != 0 { t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks) } diff --git a/pkg/bus/types.go b/pkg/bus/types.go index 7a8ca8c12..94f4954e6 100644 --- a/pkg/bus/types.go +++ b/pkg/bus/types.go @@ -30,14 +30,15 @@ type InboundMessage struct { } type OutboundMessage struct { - Channel string `json:"channel"` - ChatID string `json:"chat_id"` - Content string `json:"content"` - IsStatus bool `json:"is_status,omitempty"` - IsTaskStatus bool `json:"is_task_status,omitempty"` - TaskID string `json:"task_id,omitempty"` - Final bool `json:"final,omitempty"` // Finalize: send as permanent message, not draft - SkipPlaceholder bool `json:"skip_placeholder,omitempty"` + Channel string `json:"channel"` + ChatID string `json:"chat_id"` + Content string `json:"content"` + ReplyToMessageID string `json:"reply_to_message_id,omitempty"` + IsStatus bool `json:"is_status,omitempty"` + IsTaskStatus bool `json:"is_task_status,omitempty"` + TaskID string `json:"task_id,omitempty"` + Final bool `json:"final,omitempty"` // Finalize: send as permanent message, not draft + SkipPlaceholder bool `json:"skip_placeholder,omitempty"` } // MediaPart describes a single media attachment to send. diff --git a/pkg/channels/base.go b/pkg/channels/base.go index 5927408ee..edb5b6f08 100644 --- a/pkg/channels/base.go +++ b/pkg/channels/base.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/binary" "encoding/hex" + "regexp" "strconv" "strings" "sync/atomic" @@ -32,6 +33,9 @@ func init() { uniqueIDPrefix = hex.EncodeToString(b[:]) } +// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]). +var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) + // uniqueID generates a process-unique ID using a random prefix and an atomic counter. // This ID is intended for internal correlation (e.g. media scope keys) and is NOT // cryptographically secure — it must not be used in contexts where unpredictability matters. @@ -285,10 +289,10 @@ func (c *BaseChannel) HandleMessage( } } // Placeholder — independent pipeline. - // Skip for DraftSender channels: the streaming draft bubble serves - // as the placeholder, and sendMessage on final response replaces it. - // Sending both a placeholder AND drafts causes duplicate chat bubbles. - if _, isDrafter := c.owner.(DraftSender); !isDrafter { + // Skip when the message contains audio: the agent will send the + // placeholder after transcription completes, so the user sees + // "Thinking…" only once the voice has been processed. + if !audioAnnotationRe.MatchString(content) { if pc, ok := c.owner.(PlaceholderCapable); ok { if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go index 8642ad362..c03122892 100644 --- a/pkg/channels/dingtalk/dingtalk.go +++ b/pkg/channels/dingtalk/dingtalk.go @@ -10,6 +10,7 @@ import ( "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" @@ -39,6 +40,9 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) ( return nil, fmt.Errorf("dingtalk client_id and client_secret are required") } + // Set the logger for the Stream SDK + dinglog.SetLogger(logger.NewLogger("dingtalk")) + base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, channels.WithMaxMessageLength(20000), channels.WithGroupTrigger(cfg.GroupTrigger), diff --git a/pkg/channels/discord/discord.go b/pkg/channels/discord/discord.go index 4b89e0e07..83a04907c 100644 --- a/pkg/channels/discord/discord.go +++ b/pkg/channels/discord/discord.go @@ -45,6 +45,14 @@ type DiscordChannel struct { } func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { + discordgo.Logger = logger.NewLogger("discord"). + WithLevels(map[int]logger.LogLevel{ + discordgo.LogError: logger.ERROR, + discordgo.LogWarning: logger.WARN, + discordgo.LogInformational: logger.INFO, + discordgo.LogDebug: logger.DEBUG, + }).Log + session, err := discordgo.New("Bot " + cfg.Token) if err != nil { return nil, fmt.Errorf("failed to create discord session: %w", err) @@ -134,7 +142,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro return nil } - return c.sendChunk(ctx, channelID, msg.Content) + return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID) } // SendMedia implements the channels.MediaSender interface. @@ -232,42 +240,6 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes } } -// SendWithID implements channels.MessageSenderWithID. -// It sends a message and returns the platform message ID. -func (c *DiscordChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) { - if !c.IsRunning() { - return "", channels.ErrNotRunning - } - - if chatID == "" { - return "", fmt.Errorf("channel ID is empty") - } - - sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) - defer cancel() - - type result struct { - id string - err error - } - done := make(chan result, 1) - go func() { - msg, err := c.session.ChannelMessageSend(chatID, content) - if err != nil { - done <- result{"", fmt.Errorf("discord send: %w", channels.ErrTemporary)} - } else { - done <- result{msg.ID, nil} - } - }() - - select { - case r := <-done: - return r.id, r.err - case <-sendCtx.Done(): - return "", sendCtx.Err() - } -} - // EditMessage implements channels.MessageEditor. func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { _, err := c.session.ChannelMessageEdit(chatID, messageID, content) @@ -295,14 +267,29 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st return msg.ID, nil } -func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error { +func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error { // Use the passed ctx for timeout control sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() done := make(chan error, 1) go func() { - _, err := c.session.ChannelMessageSend(channelID, content) + var err error + + // If we have an ID, we send the message as "Reply" + if replyToID != "" { + _, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{ + Content: content, + Reference: &discordgo.MessageReference{ + MessageID: replyToID, + ChannelID: channelID, + }, + }) + } else { + // Otherwise, we send a normal message + _, err = c.session.ChannelMessageSend(channelID, content) + } + done <- err }() diff --git a/pkg/channels/feishu/feishu_64.go b/pkg/channels/feishu/feishu_64.go index 5217dd4e9..5dbbcf0af 100644 --- a/pkg/channels/feishu/feishu_64.go +++ b/pkg/channels/feishu/feishu_64.go @@ -4,11 +4,10 @@ package feishu import ( "context" - "crypto/rand" "encoding/json" "fmt" "io" - "math/big" + "math/rand" "net/http" "os" "path/filepath" @@ -201,18 +200,13 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { // Get emoji list from config emojiList := c.config.RandomReactionEmoji + var chosenEmoji string if len(emojiList) == 0 { // Default to "Pin" if no config - emojiList = []string{"Pin"} - } - - // Randomly choose one from the list using crypto/rand for better distribution - idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList)))) - var chosenEmoji string - if err != nil { - chosenEmoji = emojiList[0] + chosenEmoji = "Pin" } else { - chosenEmoji = emojiList[idx.Int64()] + idx := rand.Intn(len(emojiList)) + chosenEmoji = emojiList[idx] } req := larkim.NewCreateMessageReactionReqBuilder(). diff --git a/pkg/channels/interfaces.go b/pkg/channels/interfaces.go index 5b28f8213..b3a493761 100644 --- a/pkg/channels/interfaces.go +++ b/pkg/channels/interfaces.go @@ -26,12 +26,6 @@ type ReactionCapable interface { ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error) } -// MessageSenderWithID — channels that can send a message and return its platform-specific ID. -// Used by Manager to track status/task messages for later editing. -type MessageSenderWithID interface { - SendWithID(ctx context.Context, chatID string, content string) (messageID string, err error) -} - // PlaceholderCapable — channels that can send a placeholder message // (e.g. "Thinking... 💭") that will later be edited to the actual response. // The channel MUST also implement MessageEditor for the placeholder to be useful. @@ -41,13 +35,6 @@ type PlaceholderCapable interface { SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error) } -// DraftSender — channels that can send progressive draft messages. -// Used for streaming LLM output without the "edited" indicator. -// draftID must be non-zero and consistent across updates for the same draft. -type DraftSender interface { - SendDraft(ctx context.Context, chatID string, draftID int, content string) error -} - // PlaceholderRecorder is injected into channels by Manager. // Channels call these methods on inbound to register typing/placeholder state. // Manager uses the registered state on outbound to stop typing and edit placeholders. diff --git a/pkg/channels/interfaces_ext.go b/pkg/channels/interfaces_ext.go new file mode 100644 index 000000000..647d7cc64 --- /dev/null +++ b/pkg/channels/interfaces_ext.go @@ -0,0 +1,16 @@ +package channels + +import "context" + +// MessageSenderWithID — channels that can send a message and return its platform-specific ID. +// Used by Manager to track status/task messages for later editing. +type MessageSenderWithID interface { + SendWithID(ctx context.Context, chatID string, content string) (messageID string, err error) +} + +// DraftSender — channels that can send progressive draft messages. +// Used for streaming LLM output without the "edited" indicator. +// draftID must be non-zero and consistent across updates for the same draft. +type DraftSender interface { + SendDraft(ctx context.Context, chatID string, draftID int, content string) error +} diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go index b36350a06..56ba02183 100644 --- a/pkg/channels/line/line.go +++ b/pkg/channels/line/line.go @@ -32,6 +32,10 @@ const ( lineBotInfoEndpoint = lineAPIBase + "/info" lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" lineReplyTokenMaxAge = 25 * time.Second + + // Limit request body to prevent memory exhaustion (DoS). + // LINE webhook payloads are typically a few KB; 1 MiB is generous. + maxWebhookBodySize = 1 << 20 // 1 MiB ) type replyTokenEntry struct { @@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { return } - body, err := io.ReadAll(r.Body) + body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1)) if err != nil { logger.ErrorCF("line", "Failed to read request body", map[string]any{ "error": err.Error(), @@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "Bad request", http.StatusBadRequest) return } + if int64(len(body)) > maxWebhookBodySize { + logger.WarnC("line", "Webhook request body too large, rejected") + http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge) + return + } signature := r.Header.Get("X-Line-Signature") if !c.verifySignature(body, signature) { diff --git a/pkg/channels/line/line_test.go b/pkg/channels/line/line_test.go new file mode 100644 index 000000000..00770f1c7 --- /dev/null +++ b/pkg/channels/line/line_test.go @@ -0,0 +1,81 @@ +package line + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWebhookRejectsOversizedBody(t *testing.T) { + ch := &LINEChannel{} + + oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code) + } +} + +func TestWebhookAcceptsMaxBodySize(t *testing.T) { + ch := &LINEChannel{} + + body := bytes.Repeat([]byte("A"), maxWebhookBodySize) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + // Missing signature should be rejected, but the body size should not trigger 413. + if rec.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } +} + +func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) { + ch := &LINEChannel{} + + oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1) + req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized)) + req.Header.Set("X-Line-Signature", "invalidsignature") + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code) + } +} + +func TestWebhookRejectsNonPostMethod(t *testing.T) { + ch := &LINEChannel{} + + req := httptest.NewRequest(http.MethodGet, "/webhook", nil) + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusMethodNotAllowed { + t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code) + } +} + +func TestWebhookRejectsInvalidSignature(t *testing.T) { + ch := &LINEChannel{} + + body := `{"events":[]}` + req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + req.Header.Set("X-Line-Signature", "invalidsignature") + rec := httptest.NewRecorder() + + ch.webhookHandler(rec, req) + + if rec.Code != http.StatusForbidden { + t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code) + } +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index d09ef3eae..631f0aa1c 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -79,6 +79,7 @@ var channelRateConfig = map[string]float64{ "slack": 1, "matrix": 2, "line": 10, + "qq": 5, "irc": 2, } @@ -118,6 +119,27 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()}) } +// SendPlaceholder sends a "Thinking..." placeholder for the given channel/chatID +// and records it for later editing. Returns true if a placeholder was sent. +func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool { + m.mu.RLock() + ch, ok := m.channels[channel] + m.mu.RUnlock() + if !ok { + return false + } + pc, ok := ch.(PlaceholderCapable) + if !ok { + return false + } + phID, err := pc.SendPlaceholder(ctx, chatID) + if err != nil || phID == "" { + return false + } + m.RecordPlaceholder(channel, chatID, phID) + return true +} + // RecordTypingStop registers a typing stop function for later invocation. // Implements PlaceholderRecorder. // @@ -127,12 +149,12 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) { // consuming the *new* message's typing entry. func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { key := channel + ":" + chatID - if v, loaded := m.typingStops.Load(key); loaded { - if entry, ok := v.(typingEntry); ok { - entry.stop() // idempotent + entry := typingEntry{stop: stop, createdAt: time.Now()} + if previous, loaded := m.typingStops.Swap(key, entry); loaded { + if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil { + oldEntry.stop() } } - m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()}) } // RecordReactionUndo registers a reaction undo function for later invocation. @@ -1122,6 +1144,39 @@ func (m *Manager) UnregisterChannel(name string) { delete(m.channels, name) } +// SendMessage sends an outbound message synchronously through the channel +// worker's rate limiter and retry logic. It blocks until the message is +// delivered (or all retries are exhausted), which preserves ordering when +// a subsequent operation depends on the message having been sent. +func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error { + m.mu.RLock() + _, exists := m.channels[msg.Channel] + w, wExists := m.workers[msg.Channel] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("channel %s not found", msg.Channel) + } + if !wExists || w == nil { + return fmt.Errorf("channel %s has no active worker", msg.Channel) + } + + maxLen := 0 + if mlp, ok := w.ch.(MessageLengthProvider); ok { + maxLen = mlp.MaxMessageLength() + } + if maxLen > 0 && len([]rune(msg.Content)) > maxLen { + for _, chunk := range SplitMessage(msg.Content, maxLen) { + chunkMsg := msg + chunkMsg.Content = chunk + m.sendWithRetry(ctx, msg.Channel, w, chunkMsg) + } + } else { + m.sendWithRetry(ctx, msg.Channel, w, msg) + } + return nil +} + func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { m.mu.RLock() _, exists := m.channels[channelName] diff --git a/pkg/channels/manager_ext_test.go b/pkg/channels/manager_ext_test.go new file mode 100644 index 000000000..b54c6ec6a --- /dev/null +++ b/pkg/channels/manager_ext_test.go @@ -0,0 +1,895 @@ +package channels + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" + + "golang.org/x/time/rate" + + "github.com/sipeed/picoclaw/pkg/bus" +) + +// mockEditorWithSendID implements MessageEditor and MessageSenderWithID. +type mockEditorWithSendID struct { + mockChannel + editFn func(ctx context.Context, chatID, messageID, content string) error + sendWithID func(ctx context.Context, chatID, content string) (string, error) +} + +func (m *mockEditorWithSendID) EditMessage( + ctx context.Context, chatID, messageID, content string, +) error { + return m.editFn(ctx, chatID, messageID, content) +} + +func (m *mockEditorWithSendID) SendWithID(ctx context.Context, chatID, content string) (string, error) { + return m.sendWithID(ctx, chatID, content) +} + +func TestHandleStatusSend_EditsPlaceholder(t *testing.T) { + m := newTestManager() + var editCalled bool + var editedContent string + + ch := &mockEditorWithSendID{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + editFn: func(_ context.Context, _, messageID, content string) error { + editCalled = true + editedContent = content + if messageID != "ph-42" { + t.Fatalf("expected messageID ph-42, got %s", messageID) + } + return nil + }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + t.Fatal("SendWithID should not be called when placeholder exists") + return "", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + m.RecordPlaceholder("test", "123", "ph-42") + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "status update 1", IsStatus: true} + m.handleStatusSend(context.Background(), "test", w, msg) + + if !editCalled { + t.Fatal("expected EditMessage to be called on placeholder") + } + if editedContent != "status update 1" { + t.Fatalf("expected content 'status update 1', got %s", editedContent) + } +} + +func TestHandleStatusSend_EditsTrackedStatus(t *testing.T) { + m := newTestManager() + var editCalled bool + + ch := &mockEditorWithSendID{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + editFn: func(_ context.Context, _, messageID, _ string) error { + editCalled = true + if messageID != "status-99" { + t.Fatalf("expected messageID status-99, got %s", messageID) + } + return nil + }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + t.Fatal("SendWithID should not be called when statusMsgID exists") + return "", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-99", createdAt: time.Now()}) + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "update 2", IsStatus: true} + m.handleStatusSend(context.Background(), "test", w, msg) + + if !editCalled { + t.Fatal("expected EditMessage to be called on tracked status message") + } +} + +func TestHandleStatusSend_SendsNewAndTracks(t *testing.T) { + m := newTestManager() + var sendWithIDCalled bool + + ch := &mockEditorWithSendID{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + return nil + }, + sendWithID: func(_ context.Context, chatID, content string) (string, error) { + sendWithIDCalled = true + if chatID != "123" { + t.Fatalf("expected chatID 123, got %s", chatID) + } + return "new-msg-1", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "first status", IsStatus: true} + m.handleStatusSend(context.Background(), "test", w, msg) + + if !sendWithIDCalled { + t.Fatal("expected SendWithID to be called") + } + + v, ok := m.statusMsgIDs.Load("test:123") + if !ok { + t.Fatal("expected statusMsgIDs to contain tracked entry") + } + entry := v.(statusMsgEntry) + if entry.messageID != "new-msg-1" { + t.Fatalf("expected messageID new-msg-1, got %s", entry.messageID) + } +} + +func TestHandleTaskStatusSend_EditsExisting(t *testing.T) { + m := newTestManager() + var editCalled bool + + ch := &mockEditorWithSendID{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + editFn: func(_ context.Context, _, messageID, content string) error { + editCalled = true + if messageID != "task-msg-1" { + t.Fatalf("expected messageID task-msg-1, got %s", messageID) + } + if content != "task progress 50%" { + t.Fatalf("expected content 'task progress 50%%', got %s", content) + } + return nil + }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + t.Fatal("SendWithID should not be called when task message exists") + return "", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + m.taskMsgIDs.Store( + taskStatusKey("test", "123", "task-abc"), + statusMsgEntry{messageID: "task-msg-1", createdAt: time.Now()}, + ) + + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "task progress 50%", + IsTaskStatus: true, + TaskID: "task-abc", + } + m.handleTaskStatusSend(context.Background(), "test", w, msg) + + if !editCalled { + t.Fatal("expected EditMessage to be called") + } +} + +func TestHandleTaskStatusSend_SendsNewAndTracks(t *testing.T) { + m := newTestManager() + var sendWithIDCalled bool + + ch := &mockEditorWithSendID{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + editFn: func(_ context.Context, _, _, _ string) error { return nil }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + sendWithIDCalled = true + return "new-task-msg", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "task started", + IsTaskStatus: true, + TaskID: "task-xyz", + } + m.handleTaskStatusSend(context.Background(), "test", w, msg) + + if !sendWithIDCalled { + t.Fatal("expected SendWithID to be called") + } + + v, ok := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-xyz")) + if !ok { + t.Fatal("expected taskMsgIDs to contain tracked entry") + } + entry := v.(statusMsgEntry) + if entry.messageID != "new-task-msg" { + t.Fatalf("expected messageID new-task-msg, got %s", entry.messageID) + } +} + +func TestHandleTaskStatusSend_FallbackToSend(t *testing.T) { + m := newTestManager() + var sendCalled bool + + ch := &mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + sendCalled = true + if msg.Content != "task status" { + t.Fatalf("expected content 'task status', got %s", msg.Content) + } + return nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "task status", + IsTaskStatus: true, + TaskID: "task-fallback", + } + m.handleTaskStatusSend(context.Background(), "test", w, msg) + + if !sendCalled { + t.Fatal("expected fallback Send to be called") + } +} + +func TestPreSend_EditsStatusMessage(t *testing.T) { + m := newTestManager() + var editCalled bool + + ch := &mockMessageEditor{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + editFn: func(_ context.Context, _, messageID, _ string) error { + editCalled = true + if messageID != "status-msg-77" { + t.Fatalf("expected messageID status-msg-77, got %s", messageID) + } + return nil + }, + } + + m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-msg-77", createdAt: time.Now()}) + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if !edited { + t.Fatal("expected preSend to return true (status message edited)") + } + if !editCalled { + t.Fatal("expected EditMessage to be called") + } + + if _, loaded := m.statusMsgIDs.Load("test:123"); loaded { + t.Fatal("expected statusMsgIDs entry to be deleted after preSend") + } +} + +func TestRunWorker_RoutesStatusMessages(t *testing.T) { + m := newTestManager() + + var regularSendCount atomic.Int32 + var sendWithIDCount atomic.Int32 + + ch := &mockEditorWithSendID{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + regularSendCount.Add(1) + return nil + }, + }, + editFn: func(_ context.Context, _, _, _ string) error { + return nil + }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + sendWithIDCount.Add(1) + return "tracked-1", nil + }, + } + + w := &channelWorker{ + ch: ch, + queue: make(chan bus.OutboundMessage, 10), + done: make(chan struct{}), + limiter: rate.NewLimiter(rate.Inf, 1), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go m.runWorker(ctx, "test", w) + + w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "status", IsStatus: true} + + w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "2", Content: "task", IsTaskStatus: true, TaskID: "t1"} + + w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "3", Content: "hello"} + + time.Sleep(200 * time.Millisecond) + + if regularSendCount.Load() != 1 { + t.Fatalf("expected 1 regular Send call, got %d", regularSendCount.Load()) + } + if sendWithIDCount.Load() != 2 { + t.Fatalf("expected 2 SendWithID calls (status + task), got %d", sendWithIDCount.Load()) + } +} + +func TestStatusMsgTTLJanitor(t *testing.T) { + m := newTestManager() + + m.statusMsgIDs.Store("test:old", statusMsgEntry{ + messageID: "old-status", + createdAt: time.Now().Add(-10 * time.Minute), + }) + m.taskMsgIDs.Store("task-old", statusMsgEntry{ + messageID: "old-task", + createdAt: time.Now().Add(-60 * time.Minute), + }) + + m.statusMsgIDs.Store("test:fresh", statusMsgEntry{ + messageID: "fresh-status", + createdAt: time.Now(), + }) + + now := time.Now() + m.statusMsgIDs.Range(func(key, value any) bool { + if entry, ok := value.(statusMsgEntry); ok { + if now.Sub(entry.createdAt) > statusMsgTTL { + m.statusMsgIDs.Delete(key) + } + } + return true + }) + m.taskMsgIDs.Range(func(key, value any) bool { + if entry, ok := value.(statusMsgEntry); ok { + if now.Sub(entry.createdAt) > taskMsgTTL { + m.taskMsgIDs.Delete(key) + } + } + return true + }) + + if _, loaded := m.statusMsgIDs.Load("test:old"); loaded { + t.Fatal("expected old status entry to be evicted") + } + if _, loaded := m.taskMsgIDs.Load("task-old"); loaded { + t.Fatal("expected old task entry to be evicted") + } + if _, loaded := m.statusMsgIDs.Load("test:fresh"); !loaded { + t.Fatal("expected fresh status entry to survive") + } +} + +// mockDraftSender implements DraftSender + MessageSenderWithID + MessageEditor. +type mockDraftSender struct { + mockChannel + draftFn func(ctx context.Context, chatID string, draftID int, content string) error + editFn func(ctx context.Context, chatID, messageID, content string) error + sendWithID func(ctx context.Context, chatID, content string) (string, error) +} + +func (m *mockDraftSender) EditMessage( + ctx context.Context, chatID, messageID, content string, +) error { + return m.editFn(ctx, chatID, messageID, content) +} + +func (m *mockDraftSender) SendDraft(ctx context.Context, chatID string, draftID int, content string) error { + return m.draftFn(ctx, chatID, draftID, content) +} + +func (m *mockDraftSender) SendWithID(ctx context.Context, chatID, content string) (string, error) { + return m.sendWithID(ctx, chatID, content) +} + +func TestHandleStatusSend_UsesDraftSender(t *testing.T) { + m := newTestManager() + var draftCalled bool + var draftContent string + var draftDID int + + ch := &mockDraftSender{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + draftFn: func(_ context.Context, chatID string, draftID int, content string) error { + draftCalled = true + draftContent = content + draftDID = draftID + return nil + }, + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("EditMessage should not be called when draft succeeds") + return nil + }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + t.Fatal("SendWithID should not be called when draft succeeds") + return "", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "streaming preview", IsStatus: true} + m.handleStatusSend(context.Background(), "test", w, msg) + + if !draftCalled { + t.Fatal("expected SendDraft to be called") + } + if draftContent != "streaming preview" { + t.Fatalf("expected draft content 'streaming preview', got %s", draftContent) + } + if draftDID == 0 { + t.Fatal("expected non-zero draftID") + } + + draftCalled = false + var secondDID int + ch.draftFn = func(_ context.Context, _ string, draftID int, _ string) error { + draftCalled = true + secondDID = draftID + return nil + } + msg.Content = "streaming preview updated" + m.handleStatusSend(context.Background(), "test", w, msg) + + if !draftCalled { + t.Fatal("expected SendDraft to be called again") + } + if secondDID != draftDID { + t.Fatalf("expected same draftID %d, got %d", draftDID, secondDID) + } +} + +func TestHandleStatusSend_DraftFails_FallsToEdit(t *testing.T) { + m := newTestManager() + var editCalled bool + + ch := &mockDraftSender{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + draftFn: func(_ context.Context, _ string, _ int, _ string) error { + return fmt.Errorf("draft not supported in group") + }, + editFn: func(_ context.Context, _, _, _ string) error { + editCalled = true + return nil + }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + return "msg-1", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "preview", IsStatus: true} + m.handleStatusSend(context.Background(), "test", w, msg) + + if editCalled { + t.Fatal("expected EditMessage NOT to be called (no placeholder)") + } +} + +func TestHandleStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) { + m := newTestManager() + var sendWithIDCount int + var editCount int + var editedMessageID string + + ch := &mockDraftSender{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + draftFn: func(_ context.Context, _ string, _ int, _ string) error { + return fmt.Errorf("draft unsupported") + }, + editFn: func(_ context.Context, _, messageID, _ string) error { + editCount++ + editedMessageID = messageID + return nil + }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + sendWithIDCount++ + return "msg-1", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + msg := bus.OutboundMessage{Channel: "test", ChatID: "group-main", Content: "preview-1", IsStatus: true} + m.handleStatusSend(context.Background(), "test", w, msg) + + msg.Content = "preview-2" + m.handleStatusSend(context.Background(), "test", w, msg) + + if sendWithIDCount != 1 { + t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount) + } + if editCount != 1 { + t.Fatalf("expected EditMessage to be called once, got %d", editCount) + } + if editedMessageID != "msg-1" { + t.Fatalf("expected EditMessage target msg-1, got %s", editedMessageID) + } +} + +func TestHandleTaskStatusSend_UsesDraftSender(t *testing.T) { + m := newTestManager() + var draftCalled bool + + ch := &mockDraftSender{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + draftFn: func(_ context.Context, _ string, _ int, _ string) error { + draftCalled = true + return nil + }, + editFn: func(_ context.Context, _, _, _ string) error { + t.Fatal("EditMessage should not be called when draft succeeds") + return nil + }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + t.Fatal("SendWithID should not be called when draft succeeds") + return "", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "task progress 50%", + IsTaskStatus: true, + TaskID: "task-draft", + } + m.handleTaskStatusSend(context.Background(), "test", w, msg) + + if !draftCalled { + t.Fatal("expected SendDraft to be called for task status") + } +} + +func TestHandleTaskStatusSend_Final_UpdatesDraftInPlace(t *testing.T) { + m := newTestManager() + var draftUpdateCalled bool + var draftUpdateDraftID int + var draftUpdateContent string + + ch := &mockDraftSender{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + t.Fatal("Send should not be called when draft update succeeds") + return nil + }, + }, + draftFn: func(_ context.Context, chatID string, draftID int, content string) error { + draftUpdateCalled = true + draftUpdateDraftID = draftID + draftUpdateContent = content + if chatID != "123" { + t.Fatalf("expected chatID 123, got %s", chatID) + } + return nil + }, + editFn: func(_ context.Context, _, _, _ string) error { return nil }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + t.Fatal("SendWithID should not be called when draft update succeeds") + return "", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + m.taskMsgIDs.Store(taskStatusKey("test", "123", "task-final"), statusMsgEntry{draftID: 42, createdAt: time.Now()}) + m.statusEditTimes.Store(taskStatusKey("test", "123", "task-final"), time.Now()) + + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "task completed", + IsTaskStatus: true, + TaskID: "task-final", + Final: true, + } + m.handleTaskStatusSend(context.Background(), "test", w, msg) + + if !draftUpdateCalled { + t.Fatal("expected SendDraft to update draft with final content") + } + if draftUpdateDraftID != 42 { + t.Fatalf("expected draftID 42, got %d", draftUpdateDraftID) + } + if draftUpdateContent != "task completed" { + t.Fatalf("expected draft content 'task completed', got %q", draftUpdateContent) + } + if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-final")); loaded { + t.Fatal("expected taskMsgIDs entry to be deleted for final task status") + } + if _, loaded := m.statusEditTimes.Load(taskStatusKey("test", "123", "task-final")); loaded { + t.Fatal("expected statusEditTimes entry to be deleted for final task status") + } +} + +func TestHandleTaskStatusSend_DraftStreaming_IsolatedByChatThread(t *testing.T) { + m := newTestManager() + + type draftCall struct { + chatID string + draftID int + content string + } + calls := make([]draftCall, 0, 2) + + ch := &mockDraftSender{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + draftFn: func(_ context.Context, chatID string, draftID int, content string) error { + calls = append(calls, draftCall{chatID: chatID, draftID: draftID, content: content}) + return nil + }, + editFn: func(_ context.Context, _, _, _ string) error { return nil }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + msgA := bus.OutboundMessage{ + Channel: "test", + ChatID: "-100/10", + Content: "A:10%", + IsTaskStatus: true, + TaskID: "shared-task", + } + msgB := bus.OutboundMessage{ + Channel: "test", + ChatID: "-100/20", + Content: "B:10%", + IsTaskStatus: true, + TaskID: "shared-task", + } + + m.handleTaskStatusSend(context.Background(), "test", w, msgA) + m.handleTaskStatusSend(context.Background(), "test", w, msgB) + + if len(calls) != 2 { + t.Fatalf("expected 2 SendDraft calls, got %d", len(calls)) + } + if calls[0].chatID == calls[1].chatID { + t.Fatalf("expected different chat threads, got %q and %q", calls[0].chatID, calls[1].chatID) + } + if calls[0].draftID == calls[1].draftID { + t.Fatalf("expected distinct draft IDs per thread key, both got %d", calls[0].draftID) + } + + if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/10", "shared-task")); !loaded { + t.Fatal("expected taskMsgIDs entry for thread A") + } + if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/20", "shared-task")); !loaded { + t.Fatal("expected taskMsgIDs entry for thread B") + } +} + +func TestHandleTaskStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) { + m := newTestManager() + var sendWithIDCount int + var editCount int + var editedMessageID string + + ch := &mockDraftSender{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + draftFn: func(_ context.Context, _ string, _ int, _ string) error { + return fmt.Errorf("draft unsupported") + }, + editFn: func(_ context.Context, _, messageID, _ string) error { + editCount++ + editedMessageID = messageID + return nil + }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { + sendWithIDCount++ + return "task-msg-1", nil + }, + } + + w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "group-main", + Content: "task-10%", + IsTaskStatus: true, + TaskID: "task-1", + } + m.handleTaskStatusSend(context.Background(), "test", w, msg) + + msg.Content = "task-20%" + m.handleTaskStatusSend(context.Background(), "test", w, msg) + + if sendWithIDCount != 1 { + t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount) + } + if editCount != 1 { + t.Fatalf("expected EditMessage to be called once, got %d", editCount) + } + if editedMessageID != "task-msg-1" { + t.Fatalf("expected EditMessage target task-msg-1, got %s", editedMessageID) + } +} + +func TestPreSend_ClearsDraftState(t *testing.T) { + m := newTestManager() + + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + } + + m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()}) + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if edited { + t.Fatal("expected preSend to return false for draft-based status (sendMessage replaces draft)") + } + + if _, loaded := m.statusMsgIDs.Load("test:123"); loaded { + t.Fatal("expected draft status entry to be deleted after preSend") + } +} + +func TestGenerateDraftID_Stable(t *testing.T) { + id1 := generateDraftID("telegram:123") + id2 := generateDraftID("telegram:123") + if id1 != id2 { + t.Fatalf("expected stable draft ID, got %d vs %d", id1, id2) + } + if id1 == 0 { + t.Fatal("expected non-zero draft ID") + } + + id3 := generateDraftID("telegram:456") + if id1 == id3 { + t.Fatalf("expected different draft IDs for different keys, both got %d", id1) + } +} + +// TestPreSend_DismissesDraftBeforeSend verifies that preSend explicitly +// dismisses a draft-based status bubble (via SendDraft with empty text) +// before proceeding to send the permanent message. This prevents ghost +// draft bubbles when a user message arrives between the last draft update +// and the final sendMessage. +// TestPreSend_DismissesDraftBeforeSend verifies that preSend explicitly +// dismisses a draft-based status bubble (via SendDraft with empty text) +// before proceeding to send the permanent message. This prevents ghost +// draft bubbles when a user message arrives between the last draft update +// and the final sendMessage. +func TestPreSend_DismissesDraftBeforeSend(t *testing.T) { + m := newTestManager() + + var dismissCalled bool + var dismissContent string + + ch := &mockDraftSender{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + draftFn: func(_ context.Context, _ string, _ int, content string) error { + dismissCalled = true + dismissContent = content + return nil + }, + editFn: func(_ context.Context, _, _, _ string) error { return nil }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil }, + } + + m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()}) + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"} + edited := m.preSend(context.Background(), "test", msg, ch) + + if edited { + t.Fatal("expected preSend to return false for draft-based status") + } + if !dismissCalled { + t.Fatal("expected preSend to call SendDraft to dismiss the draft") + } + if dismissContent != "" { + t.Fatalf("expected empty dismiss content, got %q", dismissContent) + } +} + +// TestRecordTypingStop_CleansUpOldEntry verifies that recording a new +// typing stop function calls the previous stop first. +// TestRecordTypingStop_CleansUpOldEntry verifies that recording a new +// typing stop function calls the previous stop first. +func TestRecordTypingStop_CleansUpOldEntry(t *testing.T) { + m := newTestManager() + + var oldStopped atomic.Bool + + m.RecordTypingStop("tg", "42", func() { oldStopped.Store(true) }) + + m.RecordTypingStop("tg", "42", func() {}) + + if !oldStopped.Load() { + t.Fatal("expected old typing stop to be called when new entry is recorded") + } +} + +// TestRecordReactionUndo_CleansUpOldEntry verifies that recording a new +// reaction undo function calls the previous undo first. +// TestRecordReactionUndo_CleansUpOldEntry verifies that recording a new +// reaction undo function calls the previous undo first. +func TestRecordReactionUndo_CleansUpOldEntry(t *testing.T) { + m := newTestManager() + + var oldUndone atomic.Bool + + m.RecordReactionUndo("tg", "42", func() { oldUndone.Store(true) }) + + m.RecordReactionUndo("tg", "42", func() {}) + + if !oldUndone.Load() { + t.Fatal("expected old reaction undo to be called when new entry is recorded") + } +} + +// TestPreSend_DraftDismiss_ClearsEditTimes verifies that dismissing a draft +// in preSend also clears the statusEditTimes entry for that key, preventing +// stale throttle state from affecting the next processing cycle. +// TestPreSend_DraftDismiss_ClearsEditTimes verifies that dismissing a draft +// in preSend also clears the statusEditTimes entry for that key, preventing +// stale throttle state from affecting the next processing cycle. +func TestPreSend_DraftDismiss_ClearsEditTimes(t *testing.T) { + m := newTestManager() + + ch := &mockDraftSender{ + mockChannel: mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + }, + draftFn: func(_ context.Context, _ string, _ int, _ string) error { return nil }, + editFn: func(_ context.Context, _, _, _ string) error { return nil }, + sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil }, + } + + key := "test:123" + m.statusMsgIDs.Store(key, statusMsgEntry{draftID: 42, createdAt: time.Now()}) + m.statusEditTimes.Store(key, time.Now()) + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final"} + m.preSend(context.Background(), "test", msg, ch) + + if _, loaded := m.statusEditTimes.Load(key); loaded { + t.Fatal("expected statusEditTimes to be cleared after draft dismiss") + } +} diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index c7efaaea2..e0f55288a 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -17,16 +17,32 @@ import ( // mockChannel is a test double that delegates Send to a configurable function. type mockChannel struct { BaseChannel - sendFn func(ctx context.Context, msg bus.OutboundMessage) error + sendFn func(ctx context.Context, msg bus.OutboundMessage) error + sentMessages []bus.OutboundMessage + placeholdersSent int + editedMessages int + lastPlaceholderID string } func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { + m.sentMessages = append(m.sentMessages, msg) return m.sendFn(ctx, msg) } func (m *mockChannel) Start(ctx context.Context) error { return nil } func (m *mockChannel) Stop(ctx context.Context) error { return nil } +func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + m.placeholdersSent++ + m.lastPlaceholderID = "mock-ph-123" + return m.lastPlaceholderID, nil +} + +func (m *mockChannel) EditMessage(ctx context.Context, chatID, messageID, content string) error { + m.editedMessages++ + return nil +} + // newTestManager creates a minimal Manager suitable for unit tests. func newTestManager() *Manager { return &Manager{ @@ -600,6 +616,37 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) { wg.Wait() } +func TestRecordTypingStop_ReplacesExistingStop(t *testing.T) { + m := newTestManager() + var oldStopCalls int + var newStopCalls int + + m.RecordTypingStop("test", "123", func() { + oldStopCalls++ + }) + + m.RecordTypingStop("test", "123", func() { + newStopCalls++ + }) + + if oldStopCalls != 1 { + t.Fatalf("expected previous typing stop to be called once when replaced, got %d", oldStopCalls) + } + if newStopCalls != 0 { + t.Fatalf("expected replacement typing stop to stay active until preSend, got %d calls", newStopCalls) + } + + msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "hello"} + m.preSend(context.Background(), "test", msg, &mockChannel{}) + + if newStopCalls != 1 { + t.Fatalf("expected replacement typing stop to be called by preSend, got %d", newStopCalls) + } + if oldStopCalls != 1 { + t.Fatalf("expected previous typing stop to not be called again, got %d", oldStopCalls) + } +} + func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) { m := newTestManager() var sendCalled bool @@ -861,893 +908,285 @@ func TestBuildMediaScope_WithMessageID(t *testing.T) { } } -// --- Status / TaskStatus message handling tests --- +func TestManager_PlaceholderConsumedByResponse(t *testing.T) { + mgr := &Manager{ + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + placeholders: sync.Map{}, + } -// mockEditorWithSendID implements MessageEditor and MessageSenderWithID. -type mockEditorWithSendID struct { - mockChannel - editFn func(ctx context.Context, chatID, messageID, content string) error - sendWithID func(ctx context.Context, chatID, content string) (string, error) -} - -func (m *mockEditorWithSendID) EditMessage(ctx context.Context, chatID, messageID, content string) error { - return m.editFn(ctx, chatID, messageID, content) -} - -func (m *mockEditorWithSendID) SendWithID(ctx context.Context, chatID, content string) (string, error) { - return m.sendWithID(ctx, chatID, content) -} - -func TestHandleStatusSend_EditsPlaceholder(t *testing.T) { - m := newTestManager() - var editCalled bool - var editedContent string - - ch := &mockEditorWithSendID{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - editFn: func(_ context.Context, _, messageID, content string) error { - editCalled = true - editedContent = content - if messageID != "ph-42" { - t.Fatalf("expected messageID ph-42, got %s", messageID) - } + mockCh := &mockChannel{ + sendFn: func(ctx context.Context, msg bus.OutboundMessage) error { return nil }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - t.Fatal("SendWithID should not be called when placeholder exists") - return "", nil - }, + } + worker := newChannelWorker("mock", mockCh) + mgr.channels["mock"] = mockCh + mgr.workers["mock"] = worker + + ctx := context.Background() + key := "mock:chat-1" + + // Simulate a placeholder recorded by base.go HandleMessage + mgr.RecordPlaceholder("mock", "chat-1", "ph-123") + + if _, ok := mgr.placeholders.Load(key); !ok { + t.Fatal("expected placeholder to be recorded") } - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - // Register a placeholder - m.RecordPlaceholder("test", "123", "ph-42") - - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "status update 1", IsStatus: true} - m.handleStatusSend(context.Background(), "test", w, msg) - - if !editCalled { - t.Fatal("expected EditMessage to be called on placeholder") + // Transcription feedback arrives first — it should consume the placeholder + // and be delivered via EditMessage, not Send. + msgTranscript := bus.OutboundMessage{ + Channel: "mock", + ChatID: "chat-1", + Content: "Transcript: hello", } - if editedContent != "status update 1" { - t.Fatalf("expected content 'status update 1', got %s", editedContent) + mgr.sendWithRetry(ctx, "mock", worker, msgTranscript) + + if mockCh.editedMessages != 1 { + t.Errorf("expected 1 edited message (placeholder consumed by transcript), got %d", mockCh.editedMessages) + } + if len(mockCh.sentMessages) != 0 { + t.Errorf("expected 0 normal messages (transcript used edit), got %d", len(mockCh.sentMessages)) + } + + // Placeholder should be gone now + if _, ok := mgr.placeholders.Load(key); ok { + t.Error("expected placeholder to be removed after being consumed") + } + + // Final LLM response arrives — no placeholder left, so it goes through Send + msgFinal := bus.OutboundMessage{ + Channel: "mock", + ChatID: "chat-1", + Content: "Final Answer", + } + mgr.sendWithRetry(ctx, "mock", worker, msgFinal) + + if len(mockCh.sentMessages) != 1 { + t.Errorf("expected 1 normal message sent, got %d", len(mockCh.sentMessages)) } } -func TestHandleStatusSend_EditsTrackedStatus(t *testing.T) { +func TestSendMessage_Synchronous(t *testing.T) { m := newTestManager() - var editCalled bool - ch := &mockEditorWithSendID{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - editFn: func(_ context.Context, _, messageID, _ string) error { - editCalled = true - if messageID != "status-99" { - t.Fatalf("expected messageID status-99, got %s", messageID) - } - return nil - }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - t.Fatal("SendWithID should not be called when statusMsgID exists") - return "", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - // Pre-store a tracked status message - m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-99", createdAt: time.Now()}) - - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "update 2", IsStatus: true} - m.handleStatusSend(context.Background(), "test", w, msg) - - if !editCalled { - t.Fatal("expected EditMessage to be called on tracked status message") - } -} - -func TestHandleStatusSend_SendsNewAndTracks(t *testing.T) { - m := newTestManager() - var sendWithIDCalled bool - - ch := &mockEditorWithSendID{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - editFn: func(_ context.Context, _, _, _ string) error { - return nil - }, - sendWithID: func(_ context.Context, chatID, content string) (string, error) { - sendWithIDCalled = true - if chatID != "123" { - t.Fatalf("expected chatID 123, got %s", chatID) - } - return "new-msg-1", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - // No placeholder, no tracked status -> should use SendWithID - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "first status", IsStatus: true} - m.handleStatusSend(context.Background(), "test", w, msg) - - if !sendWithIDCalled { - t.Fatal("expected SendWithID to be called") - } - - // Verify tracked - v, ok := m.statusMsgIDs.Load("test:123") - if !ok { - t.Fatal("expected statusMsgIDs to contain tracked entry") - } - entry := v.(statusMsgEntry) - if entry.messageID != "new-msg-1" { - t.Fatalf("expected messageID new-msg-1, got %s", entry.messageID) - } -} - -func TestHandleTaskStatusSend_EditsExisting(t *testing.T) { - m := newTestManager() - var editCalled bool - - ch := &mockEditorWithSendID{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - editFn: func(_ context.Context, _, messageID, content string) error { - editCalled = true - if messageID != "task-msg-1" { - t.Fatalf("expected messageID task-msg-1, got %s", messageID) - } - if content != "task progress 50%" { - t.Fatalf("expected content 'task progress 50%%', got %s", content) - } - return nil - }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - t.Fatal("SendWithID should not be called when task message exists") - return "", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - // Pre-store task message - m.taskMsgIDs.Store( - taskStatusKey("test", "123", "task-abc"), - statusMsgEntry{messageID: "task-msg-1", createdAt: time.Now()}, - ) - - msg := bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "task progress 50%", - IsTaskStatus: true, - TaskID: "task-abc", - } - m.handleTaskStatusSend(context.Background(), "test", w, msg) - - if !editCalled { - t.Fatal("expected EditMessage to be called") - } -} - -func TestHandleTaskStatusSend_SendsNewAndTracks(t *testing.T) { - m := newTestManager() - var sendWithIDCalled bool - - ch := &mockEditorWithSendID{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - editFn: func(_ context.Context, _, _, _ string) error { return nil }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - sendWithIDCalled = true - return "new-task-msg", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - msg := bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "task started", - IsTaskStatus: true, - TaskID: "task-xyz", - } - m.handleTaskStatusSend(context.Background(), "test", w, msg) - - if !sendWithIDCalled { - t.Fatal("expected SendWithID to be called") - } - - v, ok := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-xyz")) - if !ok { - t.Fatal("expected taskMsgIDs to contain tracked entry") - } - entry := v.(statusMsgEntry) - if entry.messageID != "new-task-msg" { - t.Fatalf("expected messageID new-task-msg, got %s", entry.messageID) - } -} - -func TestHandleTaskStatusSend_FallbackToSend(t *testing.T) { - m := newTestManager() - var sendCalled bool - - // Channel without SendWithID -- only has Send + var received []bus.OutboundMessage ch := &mockChannel{ sendFn: func(_ context.Context, msg bus.OutboundMessage) error { - sendCalled = true - if msg.Content != "task status" { - t.Fatalf("expected content 'task status', got %s", msg.Content) - } + received = append(received, msg) return nil }, } - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - msg := bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "task status", - IsTaskStatus: true, - TaskID: "task-fallback", - } - m.handleTaskStatusSend(context.Background(), "test", w, msg) - - if !sendCalled { - t.Fatal("expected fallback Send to be called") - } -} - -func TestPreSend_EditsStatusMessage(t *testing.T) { - m := newTestManager() - var editCalled bool - - ch := &mockMessageEditor{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - editFn: func(_ context.Context, _, messageID, _ string) error { - editCalled = true - if messageID != "status-msg-77" { - t.Fatalf("expected messageID status-msg-77, got %s", messageID) - } - return nil - }, - } - - // Store a tracked status message - m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-msg-77", createdAt: time.Now()}) - - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"} - edited := m.preSend(context.Background(), "test", msg, ch) - - if !edited { - t.Fatal("expected preSend to return true (status message edited)") - } - if !editCalled { - t.Fatal("expected EditMessage to be called") - } - - // Verify status message was consumed (LoadAndDelete) - if _, loaded := m.statusMsgIDs.Load("test:123"); loaded { - t.Fatal("expected statusMsgIDs entry to be deleted after preSend") - } -} - -func TestRunWorker_RoutesStatusMessages(t *testing.T) { - m := newTestManager() - - var regularSendCount atomic.Int32 - var sendWithIDCount atomic.Int32 - - ch := &mockEditorWithSendID{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { - regularSendCount.Add(1) - return nil - }, - }, - editFn: func(_ context.Context, _, _, _ string) error { - return nil - }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - sendWithIDCount.Add(1) - return "tracked-1", nil - }, - } - w := &channelWorker{ ch: ch, - queue: make(chan bus.OutboundMessage, 10), - done: make(chan struct{}), limiter: rate.NewLimiter(rate.Inf, 1), } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - go m.runWorker(ctx, "test", w) - - // Send a status message for chatID "1" (routed to handleStatusSend -> SendWithID) - w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "status", IsStatus: true} - // Send a task status message for chatID "2" (routed to handleTaskStatusSend -> SendWithID) - w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "2", Content: "task", IsTaskStatus: true, TaskID: "t1"} - // Send a regular message for chatID "3" (no tracked status -> regular Send) - w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "3", Content: "hello"} - - time.Sleep(200 * time.Millisecond) - - if regularSendCount.Load() != 1 { - t.Fatalf("expected 1 regular Send call, got %d", regularSendCount.Load()) - } - if sendWithIDCount.Load() != 2 { - t.Fatalf("expected 2 SendWithID calls (status + task), got %d", sendWithIDCount.Load()) - } -} - -func TestStatusMsgTTLJanitor(t *testing.T) { - m := newTestManager() - - // Store entries with timestamps in the past - m.statusMsgIDs.Store("test:old", statusMsgEntry{ - messageID: "old-status", - createdAt: time.Now().Add(-10 * time.Minute), - }) - m.taskMsgIDs.Store("task-old", statusMsgEntry{ - messageID: "old-task", - createdAt: time.Now().Add(-60 * time.Minute), - }) - // Store a fresh entry that should survive - m.statusMsgIDs.Store("test:fresh", statusMsgEntry{ - messageID: "fresh-status", - createdAt: time.Now(), - }) - - // Simulate janitor logic - now := time.Now() - m.statusMsgIDs.Range(func(key, value any) bool { - if entry, ok := value.(statusMsgEntry); ok { - if now.Sub(entry.createdAt) > statusMsgTTL { - m.statusMsgIDs.Delete(key) - } - } - return true - }) - m.taskMsgIDs.Range(func(key, value any) bool { - if entry, ok := value.(statusMsgEntry); ok { - if now.Sub(entry.createdAt) > taskMsgTTL { - m.taskMsgIDs.Delete(key) - } - } - return true - }) - - if _, loaded := m.statusMsgIDs.Load("test:old"); loaded { - t.Fatal("expected old status entry to be evicted") - } - if _, loaded := m.taskMsgIDs.Load("task-old"); loaded { - t.Fatal("expected old task entry to be evicted") - } - if _, loaded := m.statusMsgIDs.Load("test:fresh"); !loaded { - t.Fatal("expected fresh status entry to survive") - } -} - -// --- DraftSender tests --- - -// mockDraftSender implements DraftSender + MessageSenderWithID + MessageEditor. -type mockDraftSender struct { - mockChannel - draftFn func(ctx context.Context, chatID string, draftID int, content string) error - editFn func(ctx context.Context, chatID, messageID, content string) error - sendWithID func(ctx context.Context, chatID, content string) (string, error) -} - -func (m *mockDraftSender) SendDraft(ctx context.Context, chatID string, draftID int, content string) error { - return m.draftFn(ctx, chatID, draftID, content) -} - -func (m *mockDraftSender) EditMessage(ctx context.Context, chatID, messageID, content string) error { - return m.editFn(ctx, chatID, messageID, content) -} - -func (m *mockDraftSender) SendWithID(ctx context.Context, chatID, content string) (string, error) { - return m.sendWithID(ctx, chatID, content) -} - -func TestHandleStatusSend_UsesDraftSender(t *testing.T) { - m := newTestManager() - var draftCalled bool - var draftContent string - var draftDID int - - ch := &mockDraftSender{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - draftFn: func(_ context.Context, chatID string, draftID int, content string) error { - draftCalled = true - draftContent = content - draftDID = draftID - return nil - }, - editFn: func(_ context.Context, _, _, _ string) error { - t.Fatal("EditMessage should not be called when draft succeeds") - return nil - }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - t.Fatal("SendWithID should not be called when draft succeeds") - return "", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "streaming preview", IsStatus: true} - m.handleStatusSend(context.Background(), "test", w, msg) - - if !draftCalled { - t.Fatal("expected SendDraft to be called") - } - if draftContent != "streaming preview" { - t.Fatalf("expected draft content 'streaming preview', got %s", draftContent) - } - if draftDID == 0 { - t.Fatal("expected non-zero draftID") - } - - // Second call should reuse the same draftID - draftCalled = false - var secondDID int - ch.draftFn = func(_ context.Context, _ string, draftID int, _ string) error { - draftCalled = true - secondDID = draftID - return nil - } - msg.Content = "streaming preview updated" - m.handleStatusSend(context.Background(), "test", w, msg) - - if !draftCalled { - t.Fatal("expected SendDraft to be called again") - } - if secondDID != draftDID { - t.Fatalf("expected same draftID %d, got %d", draftDID, secondDID) - } -} - -func TestHandleStatusSend_DraftFails_FallsToEdit(t *testing.T) { - m := newTestManager() - var editCalled bool - - ch := &mockDraftSender{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - draftFn: func(_ context.Context, _ string, _ int, _ string) error { - return fmt.Errorf("draft not supported in group") - }, - editFn: func(_ context.Context, _, _, _ string) error { - editCalled = true - return nil - }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - return "msg-1", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - // No existing placeholder/status — draft fails, then SendWithID - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "preview", IsStatus: true} - m.handleStatusSend(context.Background(), "test", w, msg) - - // Draft failed, so it should fall through; no placeholder → no edit → SendWithID - if editCalled { - t.Fatal("expected EditMessage NOT to be called (no placeholder)") - } -} - -func TestHandleStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) { - m := newTestManager() - var sendWithIDCount int - var editCount int - var editedMessageID string - - ch := &mockDraftSender{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - draftFn: func(_ context.Context, _ string, _ int, _ string) error { - return fmt.Errorf("draft unsupported") - }, - editFn: func(_ context.Context, _, messageID, _ string) error { - editCount++ - editedMessageID = messageID - return nil - }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - sendWithIDCount++ - return "msg-1", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - msg := bus.OutboundMessage{Channel: "test", ChatID: "group-main", Content: "preview-1", IsStatus: true} - m.handleStatusSend(context.Background(), "test", w, msg) - - msg.Content = "preview-2" - m.handleStatusSend(context.Background(), "test", w, msg) - - if sendWithIDCount != 1 { - t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount) - } - if editCount != 1 { - t.Fatalf("expected EditMessage to be called once, got %d", editCount) - } - if editedMessageID != "msg-1" { - t.Fatalf("expected EditMessage target msg-1, got %s", editedMessageID) - } -} - -func TestHandleTaskStatusSend_UsesDraftSender(t *testing.T) { - m := newTestManager() - var draftCalled bool - - ch := &mockDraftSender{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - draftFn: func(_ context.Context, _ string, _ int, _ string) error { - draftCalled = true - return nil - }, - editFn: func(_ context.Context, _, _, _ string) error { - t.Fatal("EditMessage should not be called when draft succeeds") - return nil - }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - t.Fatal("SendWithID should not be called when draft succeeds") - return "", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} + m.channels["test"] = ch + m.workers["test"] = w msg := bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "task progress 50%", - IsTaskStatus: true, - TaskID: "task-draft", + Channel: "test", + ChatID: "123", + Content: "hello world", + ReplyToMessageID: "msg-456", } - m.handleTaskStatusSend(context.Background(), "test", w, msg) - if !draftCalled { - t.Fatal("expected SendDraft to be called for task status") + err := m.SendMessage(context.Background(), msg) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + // SendMessage is synchronous — message should already be delivered + if len(received) != 1 { + t.Fatalf("expected 1 message sent, got %d", len(received)) + } + if received[0].ReplyToMessageID != "msg-456" { + t.Fatalf("expected ReplyToMessageID msg-456, got %s", received[0].ReplyToMessageID) + } + if received[0].Content != "hello world" { + t.Fatalf("expected content 'hello world', got %s", received[0].Content) } } -func TestHandleTaskStatusSend_Final_UpdatesDraftInPlace(t *testing.T) { +func TestSendMessage_UnknownChannel(t *testing.T) { m := newTestManager() - var draftUpdateCalled bool - var draftUpdateDraftID int - var draftUpdateContent string - - ch := &mockDraftSender{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { - t.Fatal("Send should not be called when draft update succeeds") - return nil - }, - }, - draftFn: func(_ context.Context, chatID string, draftID int, content string) error { - draftUpdateCalled = true - draftUpdateDraftID = draftID - draftUpdateContent = content - if chatID != "123" { - t.Fatalf("expected chatID 123, got %s", chatID) - } - return nil - }, - editFn: func(_ context.Context, _, _, _ string) error { return nil }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - t.Fatal("SendWithID should not be called when draft update succeeds") - return "", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - m.taskMsgIDs.Store(taskStatusKey("test", "123", "task-final"), statusMsgEntry{draftID: 42, createdAt: time.Now()}) - m.statusEditTimes.Store(taskStatusKey("test", "123", "task-final"), time.Now()) msg := bus.OutboundMessage{ - Channel: "test", - ChatID: "123", - Content: "task completed", - IsTaskStatus: true, - TaskID: "task-final", - Final: true, + Channel: "nonexistent", + ChatID: "123", + Content: "hello", } - m.handleTaskStatusSend(context.Background(), "test", w, msg) - if !draftUpdateCalled { - t.Fatal("expected SendDraft to update draft with final content") - } - if draftUpdateDraftID != 42 { - t.Fatalf("expected draftID 42, got %d", draftUpdateDraftID) - } - if draftUpdateContent != "task completed" { - t.Fatalf("expected draft content 'task completed', got %q", draftUpdateContent) - } - if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-final")); loaded { - t.Fatal("expected taskMsgIDs entry to be deleted for final task status") - } - if _, loaded := m.statusEditTimes.Load(taskStatusKey("test", "123", "task-final")); loaded { - t.Fatal("expected statusEditTimes entry to be deleted for final task status") + err := m.SendMessage(context.Background(), msg) + if err == nil { + t.Fatal("expected error for unknown channel") } } -func TestHandleTaskStatusSend_DraftStreaming_IsolatedByChatThread(t *testing.T) { - m := newTestManager() - - type draftCall struct { - chatID string - draftID int - content string - } - calls := make([]draftCall, 0, 2) - - ch := &mockDraftSender{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - draftFn: func(_ context.Context, chatID string, draftID int, content string) error { - calls = append(calls, draftCall{chatID: chatID, draftID: draftID, content: content}) - return nil - }, - editFn: func(_ context.Context, _, _, _ string) error { return nil }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - msgA := bus.OutboundMessage{ - Channel: "test", - ChatID: "-100/10", - Content: "A:10%", - IsTaskStatus: true, - TaskID: "shared-task", - } - msgB := bus.OutboundMessage{ - Channel: "test", - ChatID: "-100/20", - Content: "B:10%", - IsTaskStatus: true, - TaskID: "shared-task", - } - - m.handleTaskStatusSend(context.Background(), "test", w, msgA) - m.handleTaskStatusSend(context.Background(), "test", w, msgB) - - if len(calls) != 2 { - t.Fatalf("expected 2 SendDraft calls, got %d", len(calls)) - } - if calls[0].chatID == calls[1].chatID { - t.Fatalf("expected different chat threads, got %q and %q", calls[0].chatID, calls[1].chatID) - } - if calls[0].draftID == calls[1].draftID { - t.Fatalf("expected distinct draft IDs per thread key, both got %d", calls[0].draftID) - } - - if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/10", "shared-task")); !loaded { - t.Fatal("expected taskMsgIDs entry for thread A") - } - if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/20", "shared-task")); !loaded { - t.Fatal("expected taskMsgIDs entry for thread B") - } -} - -func TestHandleTaskStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) { - m := newTestManager() - var sendWithIDCount int - var editCount int - var editedMessageID string - - ch := &mockDraftSender{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - draftFn: func(_ context.Context, _ string, _ int, _ string) error { - return fmt.Errorf("draft unsupported") - }, - editFn: func(_ context.Context, _, messageID, _ string) error { - editCount++ - editedMessageID = messageID - return nil - }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { - sendWithIDCount++ - return "task-msg-1", nil - }, - } - - w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)} - - msg := bus.OutboundMessage{ - Channel: "test", - ChatID: "group-main", - Content: "task-10%", - IsTaskStatus: true, - TaskID: "task-1", - } - m.handleTaskStatusSend(context.Background(), "test", w, msg) - - msg.Content = "task-20%" - m.handleTaskStatusSend(context.Background(), "test", w, msg) - - if sendWithIDCount != 1 { - t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount) - } - if editCount != 1 { - t.Fatalf("expected EditMessage to be called once, got %d", editCount) - } - if editedMessageID != "task-msg-1" { - t.Fatalf("expected EditMessage target task-msg-1, got %s", editedMessageID) - } -} - -func TestPreSend_ClearsDraftState(t *testing.T) { +func TestSendMessage_NoWorker(t *testing.T) { m := newTestManager() ch := &mockChannel{ sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, } + m.channels["test"] = ch + // No worker registered - // Store a draft-based status entry (draftID != 0, messageID empty) - m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()}) - - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"} - edited := m.preSend(context.Background(), "test", msg, ch) - - // Draft-based entries don't trigger edit; the final sendMessage replaces the draft - if edited { - t.Fatal("expected preSend to return false for draft-based status (sendMessage replaces draft)") + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello", } - // Verify draft state was consumed - if _, loaded := m.statusMsgIDs.Load("test:123"); loaded { - t.Fatal("expected draft status entry to be deleted after preSend") + err := m.SendMessage(context.Background(), msg) + if err == nil { + t.Fatal("expected error when no worker exists") } } -func TestGenerateDraftID_Stable(t *testing.T) { - id1 := generateDraftID("telegram:123") - id2 := generateDraftID("telegram:123") - if id1 != id2 { - t.Fatalf("expected stable draft ID, got %d vs %d", id1, id2) - } - if id1 == 0 { - t.Fatal("expected non-zero draft ID") - } - - // Different key should produce different ID - id3 := generateDraftID("telegram:456") - if id1 == id3 { - t.Fatalf("expected different draft IDs for different keys, both got %d", id1) - } -} - -// TestPreSend_DismissesDraftBeforeSend verifies that preSend explicitly -// dismisses a draft-based status bubble (via SendDraft with empty text) -// before proceeding to send the permanent message. This prevents ghost -// draft bubbles when a user message arrives between the last draft update -// and the final sendMessage. -func TestPreSend_DismissesDraftBeforeSend(t *testing.T) { +func TestSendMessage_WithRetry(t *testing.T) { m := newTestManager() - var dismissCalled bool - var dismissContent string - - ch := &mockDraftSender{ - mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, - }, - draftFn: func(_ context.Context, _ string, _ int, content string) error { - dismissCalled = true - dismissContent = content + var callCount int + ch := &mockChannel{ + sendFn: func(_ context.Context, _ bus.OutboundMessage) error { + callCount++ + if callCount == 1 { + return fmt.Errorf("transient: %w", ErrTemporary) + } return nil }, - editFn: func(_ context.Context, _, _, _ string) error { return nil }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil }, } - // Store a draft-based status entry (simulates active streaming) - m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()}) - - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"} - edited := m.preSend(context.Background(), "test", msg, ch) - - if edited { - t.Fatal("expected preSend to return false for draft-based status") + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), } - if !dismissCalled { - t.Fatal("expected preSend to call SendDraft to dismiss the draft") + m.channels["test"] = ch + m.workers["test"] = w + + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "retry me", } - if dismissContent != "" { - t.Fatalf("expected empty dismiss content, got %q", dismissContent) + + err := m.SendMessage(context.Background(), msg) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if callCount != 2 { + t.Fatalf("expected 2 Send calls (1 failure + 1 success), got %d", callCount) } } -// TestRecordTypingStop_CleansUpOldEntry verifies that recording a new -// typing stop function calls the previous stop first. -func TestRecordTypingStop_CleansUpOldEntry(t *testing.T) { +func TestSendMessage_WithSplitting(t *testing.T) { m := newTestManager() - var oldStopped atomic.Bool - - m.RecordTypingStop("tg", "42", func() { oldStopped.Store(true) }) - - // Record a new one — old stop should fire - m.RecordTypingStop("tg", "42", func() {}) - - if !oldStopped.Load() { - t.Fatal("expected old typing stop to be called when new entry is recorded") - } -} - -// TestRecordReactionUndo_CleansUpOldEntry verifies that recording a new -// reaction undo function calls the previous undo first. -func TestRecordReactionUndo_CleansUpOldEntry(t *testing.T) { - m := newTestManager() - - var oldUndone atomic.Bool - - m.RecordReactionUndo("tg", "42", func() { oldUndone.Store(true) }) - - // Record a new one — old undo should fire - m.RecordReactionUndo("tg", "42", func() {}) - - if !oldUndone.Load() { - t.Fatal("expected old reaction undo to be called when new entry is recorded") - } -} - -// TestPreSend_DraftDismiss_ClearsEditTimes verifies that dismissing a draft -// in preSend also clears the statusEditTimes entry for that key, preventing -// stale throttle state from affecting the next processing cycle. -func TestPreSend_DraftDismiss_ClearsEditTimes(t *testing.T) { - m := newTestManager() - - ch := &mockDraftSender{ + var received []string + ch := &mockChannelWithLength{ mockChannel: mockChannel{ - sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil }, + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + received = append(received, msg.Content) + return nil + }, }, - draftFn: func(_ context.Context, _ string, _ int, _ string) error { return nil }, - editFn: func(_ context.Context, _, _, _ string) error { return nil }, - sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil }, + maxLen: 5, } - key := "test:123" - m.statusMsgIDs.Store(key, statusMsgEntry{draftID: 42, createdAt: time.Now()}) - m.statusEditTimes.Store(key, time.Now()) + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w - msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final"} - m.preSend(context.Background(), "test", msg, ch) + msg := bus.OutboundMessage{ + Channel: "test", + ChatID: "123", + Content: "hello world", + } - if _, loaded := m.statusEditTimes.Load(key); loaded { - t.Fatal("expected statusEditTimes to be cleared after draft dismiss") + err := m.SendMessage(context.Background(), msg) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if len(received) < 2 { + t.Fatalf("expected message to be split into at least 2 chunks, got %d", len(received)) + } +} + +func TestSendMessage_PreservesOrdering(t *testing.T) { + m := newTestManager() + + var order []string + ch := &mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + order = append(order, msg.Content) + return nil + }, + } + + w := &channelWorker{ + ch: ch, + limiter: rate.NewLimiter(rate.Inf, 1), + } + m.channels["test"] = ch + m.workers["test"] = w + + // Send two messages sequentially — they must arrive in order + _ = m.SendMessage(context.Background(), bus.OutboundMessage{ + Channel: "test", ChatID: "1", Content: "first", + }) + _ = m.SendMessage(context.Background(), bus.OutboundMessage{ + Channel: "test", ChatID: "1", Content: "second", + }) + + if len(order) != 2 { + t.Fatalf("expected 2 messages, got %d", len(order)) + } + if order[0] != "first" || order[1] != "second" { + t.Fatalf("expected [first, second], got %v", order) + } +} + +func TestManager_SendPlaceholder(t *testing.T) { + mgr := &Manager{ + channels: make(map[string]Channel), + workers: make(map[string]*channelWorker), + placeholders: sync.Map{}, + } + + mockCh := &mockChannel{ + sendFn: func(ctx context.Context, msg bus.OutboundMessage) error { + return nil + }, + } + mgr.channels["mock"] = mockCh + + ctx := context.Background() + + // SendPlaceholder should send a placeholder and record it + ok := mgr.SendPlaceholder(ctx, "mock", "chat-1") + if !ok { + t.Fatal("expected SendPlaceholder to succeed") + } + if mockCh.placeholdersSent != 1 { + t.Errorf("expected 1 placeholder sent, got %d", mockCh.placeholdersSent) + } + + key := "mock:chat-1" + if _, loaded := mgr.placeholders.Load(key); !loaded { + t.Error("expected placeholder to be recorded in manager") + } + + // SendPlaceholder on unknown channel should return false + ok = mgr.SendPlaceholder(ctx, "unknown", "chat-1") + if ok { + t.Error("expected SendPlaceholder to fail for unknown channel") } } diff --git a/pkg/channels/matrix/matrix.go b/pkg/channels/matrix/matrix.go index d51eee8fb..bec5dfdac 100644 --- a/pkg/channels/matrix/matrix.go +++ b/pkg/channels/matrix/matrix.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "html" + "io" "mime" "net/url" "os" @@ -13,6 +14,9 @@ import ( "sync" "time" + "github.com/gomarkdown/markdown" + mdhtml "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" @@ -268,6 +272,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error { return nil } +func markdownToHTML(md string) string { + p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs) + renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags}) + return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer))) +} + func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning @@ -283,16 +293,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return nil } - _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - }) + _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content)) if err != nil { return fmt.Errorf("matrix send: %w", channels.ErrTemporary) } return nil } +func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent { + mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text} + if c.config.MessageFormat != "plain" { + mc.Format = event.FormatHTML + mc.FormattedBody = markdownToHTML(text) + } + return mc +} + // SendMedia implements channels.MediaSender. func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { @@ -482,10 +498,7 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI return fmt.Errorf("matrix message ID is empty") } - editContent := &event.MessageEventContent{ - MsgType: event.MsgText, - Body: content, - } + editContent := c.messageContent(content) editContent.SetEdit(id.EventID(messageID)) _, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent) @@ -714,17 +727,23 @@ func (c *MatrixChannel) downloadMedia( reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second) defer cancel() - data, err := c.client.DownloadBytes(reqCtx, parsed) + resp, err := c.client.Download(reqCtx, parsed) if err != nil { return "", err } + defer resp.Body.Close() + + reader := resp.Body + readerClose := func() error { return nil } // Encrypted attachments put URL in msgEvt.File and require client-side decryption. if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" { - err = msgEvt.File.DecryptInPlace(data) - if err != nil { + if err = msgEvt.File.PrepareForDecryption(); err != nil { return "", fmt.Errorf("decrypt matrix media: %w", err) } + decryptReader := msgEvt.File.DecryptStream(resp.Body) + reader = decryptReader + readerClose = decryptReader.Close } label := matrixMediaLabel(msgEvt, mediaKind) @@ -737,14 +756,28 @@ func (c *MatrixChannel) downloadMedia( if err != nil { return "", err } - defer tmp.Close() + tmpPath := tmp.Name() + cleanup := true + defer func() { + _ = tmp.Close() + if cleanup { + _ = os.Remove(tmpPath) + } + }() - if _, err = tmp.Write(data); err != nil { - _ = os.Remove(tmp.Name()) + _, err = io.Copy(tmp, reader) + if err != nil { + return "", err + } + if err = readerClose(); err != nil { + return "", fmt.Errorf("decrypt matrix media: %w", err) + } + if err = tmp.Close(); err != nil { return "", err } - return tmp.Name(), nil + cleanup = false + return tmpPath, nil } func matrixContentType(msgEvt *event.MessageEventContent) string { diff --git a/pkg/channels/matrix/matrix_test.go b/pkg/channels/matrix/matrix_test.go index e76db0d3e..07a35c021 100644 --- a/pkg/channels/matrix/matrix_test.go +++ b/pkg/channels/matrix/matrix_test.go @@ -2,14 +2,19 @@ package matrix import ( "context" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" "time" "maunium.net/go/mautrix" "maunium.net/go/mautrix/event" "maunium.net/go/mautrix/id" + + "github.com/sipeed/picoclaw/pkg/config" ) func TestMatrixLocalpartMentionRegexp(t *testing.T) { @@ -194,6 +199,50 @@ func TestMatrixMediaExt(t *testing.T) { } } +func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) { + const wantBody = "matrix-media-payload" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") { + t.Fatalf("unexpected download path: %s", r.URL.Path) + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write([]byte(wantBody)) + })) + defer server.Close() + + client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "") + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + ch := &MatrixChannel{client: client} + msg := &event.MessageEventContent{ + MsgType: event.MsgImage, + Body: "image.png", + URL: id.ContentURIString("mxc://matrix.test/abc123"), + Info: &event.FileInfo{MimeType: "image/png"}, + } + + path, err := ch.downloadMedia(context.Background(), msg, "image") + if err != nil { + t.Fatalf("downloadMedia: %v", err) + } + defer os.Remove(path) + + if ext := filepath.Ext(path); ext != ".png" { + t.Fatalf("temp file extension=%q want=.png", ext) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile: %v", err) + } + if string(got) != wantBody { + t.Fatalf("file contents=%q want=%q", string(got), wantBody) + } +} + func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) { ch := &MatrixChannel{} msg := &event.MessageEventContent{ @@ -289,3 +338,50 @@ func TestMatrixOutboundContent(t *testing.T) { t.Fatalf("unexpected fallback body: %q", noCaption.Body) } } + +func TestMarkdownToHTML(t *testing.T) { + tests := []struct { + name string + input string + contains string + }{ + {"bold", "**hello**", "<strong>hello</strong>"}, + {"italic", "_world_", "<em>world</em>"}, + {"header", "### Title", "<h3"}, + {"code block", "```\nfoo()\n```", "<code>"}, + {"inline code", "`x`", "<code>x</code>"}, + {"plain text", "just text", "just text"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := markdownToHTML(tt.input) + if !strings.Contains(got, tt.contains) { + t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains) + } + }) + } +} + +func TestMessageContent(t *testing.T) { + richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}} + plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}} + defaultt := &MatrixChannel{config: config.MatrixConfig{}} + + for _, c := range []*MatrixChannel{richtext, defaultt} { + mc := c.messageContent("**hi**") + if mc.Format != event.FormatHTML { + t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format) + } + if !strings.Contains(mc.FormattedBody, "<strong>hi</strong>") { + t.Errorf("format %q: FormattedBody %q missing <strong>", c.config.MessageFormat, mc.FormattedBody) + } + if mc.Body != "**hi**" { + t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body) + } + } + + mc := plain.messageContent("**hi**") + if mc.Format != "" || mc.FormattedBody != "" { + t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody) + } +} diff --git a/pkg/channels/pico/pico.go b/pkg/channels/pico/pico.go index 4394d472a..8d8b62a67 100644 --- a/pkg/channels/pico/pico.go +++ b/pkg/channels/pico/pico.go @@ -150,26 +150,6 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return c.broadcastToSession(msg.ChatID, outMsg) } -// SendWithID implements channels.MessageSenderWithID. -// It sends a message and returns a generated message ID. -func (c *PicoChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) { - if !c.IsRunning() { - return "", channels.ErrNotRunning - } - - msgID := uuid.New().String() - outMsg := newMessage(TypeMessageCreate, map[string]any{ - "content": content, - "message_id": msgID, - }) - - if err := c.broadcastToSession(chatID, outMsg); err != nil { - return "", err - } - - return msgID, nil -} - // EditMessage implements channels.MessageEditor. func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { outMsg := newMessage(TypeMessageUpdate, map[string]any{ diff --git a/pkg/channels/qq/qq.go b/pkg/channels/qq/qq.go index 112964143..73200f64e 100644 --- a/pkg/channels/qq/qq.go +++ b/pkg/channels/qq/qq.go @@ -3,7 +3,10 @@ package qq import ( "context" "fmt" + "regexp" + "strings" "sync" + "sync/atomic" "time" "github.com/tencent-connect/botgo" @@ -20,6 +23,14 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" ) +const ( + dedupTTL = 5 * time.Minute + dedupInterval = 60 * time.Second + dedupMaxSize = 10000 // hard cap on dedup map entries + typingResend = 8 * time.Second + typingSeconds = 10 +) + type QQChannel struct { *channels.BaseChannel config config.QQConfig @@ -28,20 +39,37 @@ type QQChannel struct { ctx context.Context cancel context.CancelFunc sessionManager botgo.SessionManager - processedIDs map[string]bool - mu sync.RWMutex + + // Chat routing: track whether a chatID is group or direct. + chatType sync.Map // chatID → "group" | "direct" + + // Passive reply: store last inbound message ID per chat. + lastMsgID sync.Map // chatID → string + + // msg_seq: per-chat atomic counter for multi-part replies. + msgSeqCounters sync.Map // chatID → *atomic.Uint64 + + // Time-based dedup replacing the unbounded map. + dedup map[string]time.Time + muDedup sync.Mutex + + // done is closed on Stop to shut down the dedup janitor. + done chan struct{} + stopOnce sync.Once } func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, + channels.WithMaxMessageLength(cfg.MaxMessageLength), channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithReasoningChannelID(cfg.ReasoningChannelID), ) return &QQChannel{ - BaseChannel: base, - config: cfg, - processedIDs: make(map[string]bool), + BaseChannel: base, + config: cfg, + dedup: make(map[string]time.Time), + done: make(chan struct{}), }, nil } @@ -50,8 +78,13 @@ func (c *QQChannel) Start(ctx context.Context) error { return fmt.Errorf("QQ app_id and app_secret not configured") } + botgo.SetLogger(logger.NewLogger("botgo")) logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") + // Reinitialize shutdown signal for clean restart. + c.done = make(chan struct{}) + c.stopOnce = sync.Once{} + // create token source credentials := &token.QQBotCredentials{ AppID: c.config.AppID, @@ -99,6 +132,15 @@ func (c *QQChannel) Start(ctx context.Context) error { } }() + // start dedup janitor goroutine + go c.dedupJanitor() + + // Pre-register reasoning_channel_id as group chat if configured, + // so outbound-only destinations are routed correctly. + if c.config.ReasoningChannelID != "" { + c.chatType.Store(c.config.ReasoningChannelID, "group") + } + c.SetRunning(true) logger.InfoC("qq", "QQ bot started successfully") @@ -109,6 +151,9 @@ func (c *QQChannel) Stop(ctx context.Context) error { logger.InfoC("qq", "Stopping QQ bot") c.SetRunning(false) + // Signal the dedup janitor to stop (idempotent). + c.stopOnce.Do(func() { close(c.done) }) + if c.cancel != nil { c.cancel() } @@ -116,21 +161,82 @@ func (c *QQChannel) Stop(ctx context.Context) error { return nil } +// getChatKind returns the chat type for a given chatID ("group" or "direct"). +// Unknown chatIDs default to "group" and log a warning, since QQ group IDs are +// more common as outbound-only destinations (e.g. reasoning_channel_id). +func (c *QQChannel) getChatKind(chatID string) string { + if v, ok := c.chatType.Load(chatID); ok { + if k, ok := v.(string); ok { + return k + } + } + logger.DebugCF("qq", "Unknown chat type for chatID, defaulting to group", map[string]any{ + "chat_id": chatID, + }) + return "group" +} + func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } - // construct message + chatKind := c.getChatKind(msg.ChatID) + + // Build message with content. msgToCreate := &dto.MessageToCreate{ Content: msg.Content, + MsgType: dto.TextMsg, + } + + // Use Markdown message type if enabled in config. + if c.config.SendMarkdown { + msgToCreate.MsgType = dto.MarkdownMsg + msgToCreate.Markdown = &dto.Markdown{ + Content: msg.Content, + } + // Clear plain content to avoid sending duplicate text. + msgToCreate.Content = "" + } + + // Attach passive reply msg_id and msg_seq if available. + if v, ok := c.lastMsgID.Load(msg.ChatID); ok { + if msgID, ok := v.(string); ok && msgID != "" { + msgToCreate.MsgID = msgID + + // Increment msg_seq atomically for multi-part replies. + if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok { + if counter, ok := counterVal.(*atomic.Uint64); ok { + seq := counter.Add(1) + msgToCreate.MsgSeq = uint32(seq) + } + } + } + } + + // Sanitize URLs in group messages to avoid QQ's URL blacklist rejection. + if chatKind == "group" { + if msgToCreate.Content != "" { + msgToCreate.Content = sanitizeURLs(msgToCreate.Content) + } + if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" { + msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content) + } + } + + // Route to group or C2C. + var err error + if chatKind == "group" { + _, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate) + } else { + _, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) } - // send C2C message - _, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate) if err != nil { - logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ - "error": err.Error(), + logger.ErrorCF("qq", "Failed to send message", map[string]any{ + "chat_id": msg.ChatID, + "chat_kind": chatKind, + "error": err.Error(), }) return fmt.Errorf("qq send: %w", channels.ErrTemporary) } @@ -138,7 +244,150 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } -// handleC2CMessage handles QQ private messages +// StartTyping implements channels.TypingCapable. +// It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds. +// The returned stop function is idempotent and cancels the goroutine. +func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + // We need a stored msg_id for passive InputNotify; skip if none available. + v, ok := c.lastMsgID.Load(chatID) + if !ok { + return func() {}, nil + } + msgID, ok := v.(string) + if !ok || msgID == "" { + return func() {}, nil + } + + chatKind := c.getChatKind(chatID) + + sendTyping := func(sendCtx context.Context) { + typingMsg := &dto.MessageToCreate{ + MsgType: dto.InputNotifyMsg, + MsgID: msgID, + InputNotify: &dto.InputNotify{ + InputType: 1, + InputSecond: typingSeconds, + }, + } + + var err error + if chatKind == "group" { + _, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg) + } else { + _, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg) + } + if err != nil { + logger.DebugCF("qq", "Failed to send typing indicator", map[string]any{ + "chat_id": chatID, + "error": err.Error(), + }) + } + } + + // Send immediately. + sendTyping(c.ctx) + + typingCtx, cancel := context.WithCancel(c.ctx) + go func() { + ticker := time.NewTicker(typingResend) + defer ticker.Stop() + for { + select { + case <-typingCtx.Done(): + return + case <-ticker.C: + sendTyping(typingCtx) + } + } + }() + + return cancel, nil +} + +// SendMedia implements the channels.MediaSender interface. +// QQ RichMediaMessage requires an HTTP/HTTPS URL — local file paths are not supported. +// If part.Ref is already an http(s) URL it is used directly; otherwise we try +// the media store, and skip with a warning if the resolved path is not an HTTP URL. +func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + + chatKind := c.getChatKind(msg.ChatID) + + for _, part := range msg.Parts { + // If the ref is already an HTTP(S) URL, use it directly. + mediaURL := part.Ref + if !isHTTPURL(mediaURL) { + // Try resolving through media store. + store := c.GetMediaStore() + if store == nil { + logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{ + "ref": part.Ref, + }) + continue + } + + resolved, err := store.Resolve(part.Ref) + if err != nil { + logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{ + "ref": part.Ref, + "error": err.Error(), + }) + continue + } + + if !isHTTPURL(resolved) { + logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, local files not supported", map[string]any{ + "ref": part.Ref, + "resolved": resolved, + }) + continue + } + + mediaURL = resolved + } + + // Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file. + var fileType uint64 + switch part.Type { + case "image": + fileType = 1 + case "video": + fileType = 2 + case "audio": + fileType = 3 + default: + fileType = 4 // file + } + + richMedia := &dto.RichMediaMessage{ + FileType: fileType, + URL: mediaURL, + SrvSendMsg: true, + } + + var sendErr error + if chatKind == "group" { + _, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia) + } else { + _, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia) + } + + if sendErr != nil { + logger.ErrorCF("qq", "Failed to send media", map[string]any{ + "type": part.Type, + "chat_id": msg.ChatID, + "error": sendErr.Error(), + }) + return fmt.Errorf("qq send media: %w", channels.ErrTemporary) + } + } + + return nil +} + +// handleC2CMessage handles QQ private messages. func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { // deduplication check @@ -167,7 +416,13 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { "length": len(content), }) - // 转发到消息总线 + // Store chat routing context. + c.chatType.Store(senderID, "direct") + c.lastMsgID.Store(senderID, data.ID) + + // Reset msg_seq counter for new inbound message. + c.msgSeqCounters.Store(senderID, new(atomic.Uint64)) + metadata := map[string]string{} sender := bus.SenderInfo{ @@ -195,7 +450,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler { } } -// handleGroupATMessage handles QQ group @ messages +// handleGroupATMessage handles QQ group @ messages. func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { // deduplication check @@ -232,7 +487,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { "length": len(content), }) - // 转发到消息总线(使用 GroupID 作为 ChatID) + // Store chat routing context using GroupID as chatID. + c.chatType.Store(data.GroupID, "group") + c.lastMsgID.Store(data.GroupID, data.ID) + + // Reset msg_seq counter for new inbound message. + c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64)) + metadata := map[string]string{ "group_id": data.GroupID, } @@ -262,29 +523,102 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { } } -// isDuplicate 检查消息是否重复 +// isDuplicate checks whether a message has been seen within the TTL window. +// It also enforces a hard cap on map size by evicting oldest entries. func (c *QQChannel) isDuplicate(messageID string) bool { - c.mu.Lock() - defer c.mu.Unlock() + c.muDedup.Lock() + defer c.muDedup.Unlock() - if c.processedIDs[messageID] { + if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL { return true } - c.processedIDs[messageID] = true - - // 简单清理:限制 map 大小 - if len(c.processedIDs) > 10000 { - // 清空一半 - count := 0 - for id := range c.processedIDs { - if count >= 5000 { - break + // Enforce hard cap: evict oldest entries when at capacity. + if len(c.dedup) >= dedupMaxSize { + var oldestID string + var oldestTS time.Time + for id, ts := range c.dedup { + if oldestID == "" || ts.Before(oldestTS) { + oldestID = id + oldestTS = ts } - delete(c.processedIDs, id) - count++ + } + if oldestID != "" { + delete(c.dedup, oldestID) } } + c.dedup[messageID] = time.Now() return false } + +// dedupJanitor periodically evicts expired entries from the dedup map. +func (c *QQChannel) dedupJanitor() { + ticker := time.NewTicker(dedupInterval) + defer ticker.Stop() + + for { + select { + case <-c.done: + return + case <-ticker.C: + // Collect expired keys under read-like scan. + c.muDedup.Lock() + now := time.Now() + var expired []string + for id, ts := range c.dedup { + if now.Sub(ts) >= dedupTTL { + expired = append(expired, id) + } + } + for _, id := range expired { + delete(c.dedup, id) + } + c.muDedup.Unlock() + } + } +} + +// isHTTPURL returns true if s starts with http:// or https://. +func isHTTPURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +// urlPattern matches URLs with explicit http(s):// scheme. +// Only scheme-prefixed URLs are matched to avoid false positives on bare text +// like version numbers (e.g., "1.2.3") or domain-like fragments. +var urlPattern = regexp.MustCompile( + `(?i)` + + `https?://` + // required scheme + `(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+` + // domain parts + `[a-zA-Z]{2,}` + // TLD + `(?:[/?#]\S*)?`, // optional path/query/fragment +) + +// sanitizeURLs replaces dots in URL domains with "。" (fullwidth period) +// to prevent QQ's URL blacklist from rejecting the message. +func sanitizeURLs(text string) string { + return urlPattern.ReplaceAllStringFunc(text, func(match string) string { + // Split into scheme + rest (scheme is always present). + idx := strings.Index(match, "://") + scheme := match[:idx+3] + rest := match[idx+3:] + + // Find where the domain ends (first / ? or #). + domainEnd := len(rest) + for i, ch := range rest { + if ch == '/' || ch == '?' || ch == '#' { + domainEnd = i + break + } + } + + domain := rest[:domainEnd] + path := rest[domainEnd:] + + // Replace dots in domain only. + domain = strings.ReplaceAll(domain, ".", "。") + + return scheme + domain + path + }) +} diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 412760c98..3ee849621 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -122,7 +122,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error slack.MsgOptionText(msg.Content, false), } - if threadTS != "" { + if msg.ReplyToMessageID != "" && threadTS == "" { + // Answer to the message by creating a Thread under it + opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID)) + } else if threadTS != "" { + // If we are already in a thread, continue in the thread opts = append(opts, slack.MsgOptionTS(threadTS)) } @@ -183,7 +187,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa title = filename } - _, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{ + _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ Channel: channelID, File: localPath, Filename: filename, @@ -303,17 +307,16 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { Timestamp: messageTS, }) - var contentBuf strings.Builder - contentBuf.WriteString(c.stripBotMention(ev.Text)) + content := ev.Text + content = c.stripBotMention(content) // In non-DM channels, apply group trigger filtering if !strings.HasPrefix(channelID, "D") { - respond, cleaned := c.ShouldRespondInGroup(false, contentBuf.String()) + respond, cleaned := c.ShouldRespondInGroup(false, content) if !respond { return } - contentBuf.Reset() - contentBuf.WriteString(cleaned) + content = cleaned } var mediaPaths []string @@ -341,11 +344,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { continue } mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name)) - fmt.Fprintf(&contentBuf, "\n[file: %s]", file.Name) + content += fmt.Sprintf("\n[file: %s]", file.Name) } } - content := contentBuf.String() if strings.TrimSpace(content) == "" { return } diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index e4f4dff7a..34ee46b7b 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -77,6 +77,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" { opts = append(opts, telego.WithAPIServer(baseURL)) } + opts = append(opts, telego.WithLogger(logger.NewLogger("telego"))) bot, err := telego.NewBot(telegramCfg.Token, opts...) if err != nil { @@ -168,7 +169,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err return channels.ErrNotRunning } - chatID, threadID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -180,6 +181,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength), // so msg.Content is guaranteed to be within that limit. We still need to // check if HTML expansion pushes it beyond Telegram's 4096-char API limit. + replyToID := msg.ReplyToMessageID queue := []string{msg.Content} for len(queue) > 0 { chunk := queue[0] @@ -200,9 +202,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err continue } - if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil { + if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil { return err } + // Only the first chunk should be a reply; subsequent chunks are normal messages. + replyToID = "" } return nil @@ -210,11 +214,19 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err // sendHTMLChunk sends a single HTML message, falling back to the original // markdown as plain text on parse failure so users never see raw HTML tags. -func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error { +func (c *TelegramChannel) sendHTMLChunk( + ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, replyToID string, +) error { tgMsg := tu.Message(tu.ID(chatID), htmlContent) tgMsg.ParseMode = telego.ModeHTML - if threadID != 0 { - tgMsg.MessageThreadID = threadID + tgMsg.MessageThreadID = threadID + + if replyToID != "" { + if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil { + tgMsg.ReplyParameters = &telego.ReplyParameters{ + MessageID: mid, + } + } } if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil { @@ -230,54 +242,21 @@ func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlC return nil } -// SendWithID implements channels.MessageSenderWithID. -// It sends a message and returns the platform message ID. -func (c *TelegramChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) { - if !c.IsRunning() { - return "", channels.ErrNotRunning - } - - cid, tid, err := parseChatID(chatID) - if err != nil { - return "", fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed) - } - - htmlContent := markdownToTelegramHTML(content) - tgMsg := tu.Message(tu.ID(cid), htmlContent) - tgMsg.ParseMode = telego.ModeHTML - if tid != 0 { - tgMsg.MessageThreadID = tid - } - - sent, err := c.bot.SendMessage(ctx, tgMsg) - if err != nil { - // Fallback to plain text - tgMsg.ParseMode = "" - sent, err = c.bot.SendMessage(ctx, tgMsg) - if err != nil { - return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary) - } - } - - return fmt.Sprintf("%d", sent.MessageID), nil -} - // StartTyping implements channels.TypingCapable. // It sends ChatAction(typing) immediately and then repeats every 4 seconds // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { - cid, tid, err := parseChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return func() {}, err } + action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) + action.MessageThreadID = threadID + // Send the first typing action immediately - firstAction := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) - if tid != 0 { - firstAction.MessageThreadID = tid - } - _ = c.bot.SendChatAction(ctx, firstAction) + _ = c.bot.SendChatAction(ctx, action) typingCtx, cancel := context.WithCancel(ctx) go func() { @@ -288,11 +267,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( case <-typingCtx.Done(): return case <-ticker.C: - action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) - if tid != 0 { - action.MessageThreadID = tid - } - _ = c.bot.SendChatAction(typingCtx, action) + a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping) + a.MessageThreadID = threadID + _ = c.bot.SendChatAction(typingCtx, a) } } }() @@ -302,7 +279,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func( // EditMessage implements channels.MessageEditor. func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { - cid, _, err := parseChatID(chatID) + cid, _, err := parseTelegramChatID(chatID) if err != nil { return err } @@ -331,16 +308,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s text = "Thinking... 💭" } - cid, tid, err := parseChatID(chatID) + cid, threadID, err := parseTelegramChatID(chatID) if err != nil { return "", err } - params := tu.Message(tu.ID(cid), text) - if tid != 0 { - params.MessageThreadID = tid - } - pMsg, err := c.bot.SendMessage(ctx, params) + phMsg := tu.Message(tu.ID(cid), text) + phMsg.MessageThreadID = threadID + pMsg, err := c.bot.SendMessage(ctx, phMsg) if err != nil { return "", err } @@ -348,44 +323,13 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s return fmt.Sprintf("%d", pMsg.MessageID), nil } -// SendDraft implements channels.DraftSender. -// It uses Telegram Bot API's sendMessageDraft for progressive message streaming -// without the "edited" indicator. In groups, draft is used for dedicated topics only. -func (c *TelegramChannel) SendDraft(ctx context.Context, chatID string, draftID int, content string) error { - if !c.IsRunning() { - return channels.ErrNotRunning - } - cid, tid, err := parseChatID(chatID) - if err != nil { - return fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed) - } - if !isLikelyPrivateChatID(cid) && tid == 0 { - return fmt.Errorf("telegram draft unsupported for non-threaded group chat: %w", channels.ErrSendFailed) - } - htmlContent := markdownToTelegramHTML(content) - params := &telego.SendMessageDraftParams{ - ChatID: cid, - MessageThreadID: tid, - DraftID: draftID, - Text: htmlContent, - ParseMode: telego.ModeHTML, - } - if err = c.bot.SendMessageDraft(ctx, params); err != nil { - // HTML parse failure — retry as plain text - params.ParseMode = "" - params.Text = content - return c.bot.SendMessageDraft(ctx, params) - } - return nil -} - // SendMedia implements the channels.MediaSender interface. func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error { if !c.IsRunning() { return channels.ErrNotRunning } - chatID, threadID, err := parseChatID(msg.ChatID) + chatID, threadID, err := parseTelegramChatID(msg.ChatID) if err != nil { return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) } @@ -492,12 +436,11 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes chatID := message.Chat.ID c.chatIDs[platformID] = chatID - threadID := message.MessageThreadID content := "" mediaPaths := []string{} - chatIDStr := formatChatID(chatID, threadID) + chatIDStr := fmt.Sprintf("%d", chatID) messageIDStr := fmt.Sprintf("%d", message.MessageID) scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr) @@ -589,21 +532,28 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = cleaned } - logger.DebugCF("telegram", "Received message", map[string]any{ - "sender_id": sender.CanonicalID, - "chat_id": fmt.Sprintf("%d", chatID), - "thread_id": threadID, - "chat_route": chatIDStr, - "preview": utils.Truncate(content, 50), - }) + // For forum topics, embed the thread ID as "chatID/threadID" so replies + // route to the correct topic and each topic gets its own session. + // Only forum groups (IsForum) are handled; regular group reply threads + // must share one session per group. + compositeChatID := fmt.Sprintf("%d", chatID) + threadID := message.MessageThreadID + if message.Chat.IsForum && threadID != 0 { + compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID) + } - // Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable + logger.DebugCF("telegram", "Received message", map[string]any{ + "sender_id": sender.CanonicalID, + "chat_id": compositeChatID, + "thread_id": threadID, + "preview": utils.Truncate(content, 50), + }) peerKind := "direct" peerID := fmt.Sprintf("%d", user.ID) if message.Chat.Type != "private" { peerKind = "group" - peerID = fmt.Sprintf("%d", chatID) + peerID = compositeChatID } peer := bus.Peer{Kind: peerKind, ID: peerID} @@ -616,11 +566,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), } + // Set parent_peer metadata for per-topic agent binding. + if message.Chat.IsForum && threadID != 0 { + metadata["parent_peer_kind"] = "topic" + metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID) + } + c.HandleMessage(c.ctx, peer, messageID, platformID, - chatIDStr, + compositeChatID, content, mediaPaths, metadata, @@ -668,50 +624,25 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) return c.downloadFileWithInfo(file, ext) } -func parseChatID(chatIDStr string) (int64, int, error) { - trimmed := strings.TrimSpace(chatIDStr) - if trimmed == "" { - return 0, 0, fmt.Errorf("empty chat ID") +// parseTelegramChatID splits "chatID/threadID" into its components. +// Returns threadID=0 when no "/" is present (non-forum messages). +func parseTelegramChatID(chatID string) (int64, int, error) { + idx := strings.Index(chatID, "/") + if idx == -1 { + cid, err := strconv.ParseInt(chatID, 10, 64) + return cid, 0, err } - - parts := strings.Split(trimmed, "/") - if len(parts) > 2 { - return 0, 0, fmt.Errorf("invalid chat ID format: %q", chatIDStr) - } - - cid, err := strconv.ParseInt(parts[0], 10, 64) + cid, err := strconv.ParseInt(chatID[:idx], 10, 64) if err != nil { - return 0, 0, fmt.Errorf("invalid chat ID %q: %w", parts[0], err) + return 0, 0, err } - - tid := 0 - if len(parts) == 2 { - if parts[1] == "" { - return 0, 0, fmt.Errorf("invalid thread ID in %q", chatIDStr) - } - tid, err = strconv.Atoi(parts[1]) - if err != nil { - return 0, 0, fmt.Errorf("invalid thread ID %q: %w", parts[1], err) - } - if tid < 0 { - return 0, 0, fmt.Errorf("thread ID must be non-negative: %d", tid) - } + tid, err := strconv.Atoi(chatID[idx+1:]) + if err != nil { + return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err) } - return cid, tid, nil } -func formatChatID(chatID int64, threadID int) string { - if threadID != 0 { - return fmt.Sprintf("%d/%d", chatID, threadID) - } - return fmt.Sprintf("%d", chatID) -} - -func isLikelyPrivateChatID(chatID int64) bool { - return chatID > 0 -} - func markdownToTelegramHTML(text string) string { if text == "" { return "" diff --git a/pkg/channels/telegram/telegram_ext.go b/pkg/channels/telegram/telegram_ext.go new file mode 100644 index 000000000..76ed9afdf --- /dev/null +++ b/pkg/channels/telegram/telegram_ext.go @@ -0,0 +1,85 @@ +package telegram + +import ( + "context" + "fmt" + + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + + "github.com/sipeed/picoclaw/pkg/channels" +) + +// SendWithID implements channels.MessageSenderWithID. +// It sends a message and returns the platform message ID. +func (c *TelegramChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) { + if !c.IsRunning() { + return "", channels.ErrNotRunning + } + + cid, tid, err := parseTelegramChatID(chatID) + if err != nil { + return "", fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed) + } + + htmlContent := markdownToTelegramHTML(content) + tgMsg := tu.Message(tu.ID(cid), htmlContent) + tgMsg.ParseMode = telego.ModeHTML + tgMsg.MessageThreadID = tid + + sent, err := c.bot.SendMessage(ctx, tgMsg) + if err != nil { + // Fallback to plain text + tgMsg.ParseMode = "" + sent, err = c.bot.SendMessage(ctx, tgMsg) + if err != nil { + return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary) + } + } + + return fmt.Sprintf("%d", sent.MessageID), nil +} + +// SendDraft implements channels.DraftSender. +// It uses Telegram Bot API's sendMessageDraft for progressive message streaming +// without the "edited" indicator. In groups, draft is used for dedicated topics only. +func (c *TelegramChannel) SendDraft(ctx context.Context, chatID string, draftID int, content string) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + cid, tid, err := parseTelegramChatID(chatID) + if err != nil { + return fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed) + } + if !isLikelyPrivateChatID(cid) && tid == 0 { + return fmt.Errorf("telegram draft unsupported for non-threaded group chat: %w", channels.ErrSendFailed) + } + htmlContent := markdownToTelegramHTML(content) + params := &telego.SendMessageDraftParams{ + ChatID: cid, + MessageThreadID: tid, + DraftID: draftID, + Text: htmlContent, + ParseMode: telego.ModeHTML, + } + if err = c.bot.SendMessageDraft(ctx, params); err != nil { + // HTML parse failure — retry as plain text + params.ParseMode = "" + params.Text = content + return c.bot.SendMessageDraft(ctx, params) + } + return nil +} + +// formatChatID formats a chat ID with optional thread ID as "chatID/threadID". +func formatChatID(chatID int64, threadID int) string { + if threadID != 0 { + return fmt.Sprintf("%d/%d", chatID, threadID) + } + return fmt.Sprintf("%d", chatID) +} + +// isLikelyPrivateChatID returns true for positive chat IDs (private chats). +func isLikelyPrivateChatID(chatID int64) bool { + return chatID > 0 +} diff --git a/pkg/channels/telegram/telegram_ext_test.go b/pkg/channels/telegram/telegram_ext_test.go new file mode 100644 index 000000000..783e0ef5c --- /dev/null +++ b/pkg/channels/telegram/telegram_ext_test.go @@ -0,0 +1,54 @@ +package telegram + +import ( + "testing" +) + +func TestParseTelegramChatID(t *testing.T) { + tests := []struct { + name string + input string + wantCID int64 + wantTID int + wantErr bool + }{ + {name: "plain private", input: "12345", wantCID: 12345, wantTID: 0}, + {name: "group topic", input: "-100123/45", wantCID: -100123, wantTID: 45}, + {name: "topic zero", input: "-100/0", wantCID: -100, wantTID: 0}, + {name: "bad chat", input: "abc/def", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotCID, gotTID, err := parseTelegramChatID(tc.input) + if tc.wantErr { + if err == nil { + t.Fatalf("parseTelegramChatID(%q) expected error, got nil", tc.input) + } + return + } + if err != nil { + t.Fatalf("parseTelegramChatID(%q) unexpected error: %v", tc.input, err) + } + if gotCID != tc.wantCID || gotTID != tc.wantTID { + t.Fatalf( + "parseTelegramChatID(%q) = (%d, %d), want (%d, %d)", + tc.input, + gotCID, + gotTID, + tc.wantCID, + tc.wantTID, + ) + } + }) + } +} + +func TestFormatChatID(t *testing.T) { + if got := formatChatID(-100, 42); got != "-100/42" { + t.Fatalf("formatChatID(-100, 42) = %q, want %q", got, "-100/42") + } + if got := formatChatID(12345, 0); got != "12345" { + t.Fatalf("formatChatID(12345, 0) = %q, want %q", got, "12345") + } +} diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go index 83588f926..c2186d0a3 100644 --- a/pkg/channels/telegram/telegram_test.go +++ b/pkg/channels/telegram/telegram_test.go @@ -1,50 +1,462 @@ package telegram -import "testing" +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" -func TestParseChatID(t *testing.T) { - tests := []struct { - name string - input string - wantCID int64 - wantTID int - wantErr bool - }{ - {name: "plain private", input: "12345", wantCID: 12345, wantTID: 0}, - {name: "group topic", input: "-100123/45", wantCID: -100123, wantTID: 45}, - {name: "trim spaces", input: " -100200/7 ", wantCID: -100200, wantTID: 7}, - {name: "topic zero", input: "-100/0", wantCID: -100, wantTID: 0}, - {name: "empty", input: "", wantErr: true}, - {name: "bad chat", input: "abc/def", wantErr: true}, - {name: "missing topic", input: "-100/", wantErr: true}, - {name: "too many parts", input: "-100/1/2", wantErr: true}, - {name: "negative topic", input: "-100/-1", wantErr: true}, - } + "github.com/mymmrac/telego" + ta "github.com/mymmrac/telego/telegoapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - gotCID, gotTID, err := parseChatID(tc.input) - if tc.wantErr { - if err == nil { - t.Fatalf("parseChatID(%q) expected error, got nil", tc.input) - } - return - } - if err != nil { - t.Fatalf("parseChatID(%q) unexpected error: %v", tc.input, err) - } - if gotCID != tc.wantCID || gotTID != tc.wantTID { - t.Fatalf("parseChatID(%q) = (%d, %d), want (%d, %d)", tc.input, gotCID, gotTID, tc.wantCID, tc.wantTID) - } - }) + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" +) + +const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc" + +// stubCaller implements ta.Caller for testing. +type stubCaller struct { + calls []stubCall + callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) +} + +type stubCall struct { + URL string + Data *ta.RequestData +} + +func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + s.calls = append(s.calls, stubCall{URL: url, Data: data}) + return s.callFn(ctx, url, data) +} + +// stubConstructor implements ta.RequestConstructor for testing. +type stubConstructor struct{} + +func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) { + return &ta.RequestData{}, nil +} + +func (s *stubConstructor) MultipartRequest( + parameters map[string]string, + files map[string]ta.NamedReader, +) (*ta.RequestData, error) { + return &ta.RequestData{}, nil +} + +// successResponse returns a ta.Response that telego will treat as a successful SendMessage. +func successResponse(t *testing.T) *ta.Response { + t.Helper() + msg := &telego.Message{MessageID: 1} + b, err := json.Marshal(msg) + require.NoError(t, err) + return &ta.Response{Ok: true, Result: b} +} + +// newTestChannel creates a TelegramChannel with a mocked bot for unit testing. +func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel { + t.Helper() + + bot, err := telego.NewBot(testToken, + telego.WithAPICaller(caller), + telego.WithRequestConstructor(&stubConstructor{}), + telego.WithDiscardLogger(), + ) + require.NoError(t, err) + + base := channels.NewBaseChannel("telegram", nil, nil, nil, + channels.WithMaxMessageLength(4000), + ) + base.SetRunning(true) + + return &TelegramChannel{ + BaseChannel: base, + bot: bot, + chatIDs: make(map[string]int64), } } -func TestFormatChatID(t *testing.T) { - if got := formatChatID(-100, 42); got != "-100/42" { - t.Fatalf("formatChatID(-100, 42) = %q, want %q", got, "-100/42") - } - if got := formatChatID(12345, 0); got != "12345" { - t.Fatalf("formatChatID(12345, 0) = %q, want %q", got, "12345") +func TestSend_EmptyContent(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + t.Fatal("SendMessage should not be called for empty content") + return nil, nil + }, } + ch := newTestChannel(t, caller) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "", + }) + + assert.NoError(t, err) + assert.Empty(t, caller.calls, "no API calls should be made for empty content") +} + +func TestSend_ShortMessage_SingleCall(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "Hello, world!", + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call") +} + +func TestSend_LongMessage_SingleCall(t *testing.T) { + // With WithMaxMessageLength(4000), the Manager pre-splits messages before + // they reach Send(). A message at exactly 4000 chars should go through + // as a single SendMessage call (no re-split needed since HTML expansion + // won't exceed 4096 for plain text). + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + longContent := strings.Repeat("a", 4000) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: longContent, + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call") +} + +func TestSend_HTMLFallback_PerChunk(t *testing.T) { + callCount := 0 + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + callCount++ + // Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback) + if callCount%2 == 1 { + return nil, errors.New("Bad Request: can't parse entities") + } + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "Hello **world**", + }) + + assert.NoError(t, err) + // One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls + assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback") +} + +func TestSend_HTMLFallback_BothFail(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return nil, errors.New("send failed") + }, + } + ch := newTestChannel(t, caller) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "Hello", + }) + + assert.Error(t, err) + assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary") + assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt") +} + +func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) { + // With a long message that gets split into 2 chunks, if both HTML and + // plain text fail on the first chunk, Send should return early. + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return nil, errors.New("send failed") + }, + } + ch := newTestChannel(t, caller) + + longContent := strings.Repeat("x", 4001) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: longContent, + }) + + assert.Error(t, err) + // Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk. + assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text") +} + +func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + // Create markdown whose length is <= 4000 but whose HTML expansion is much longer. + // "**a** " (6 chars) becomes "<b>a</b> " (9 chars) in HTML, so repeating it many times + // yields HTML that exceeds Telegram's limit while markdown stays within it. + markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars + assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size") + + htmlExpanded := markdownToTelegramHTML(markdownContent) + assert.Greater( + t, len([]rune(htmlExpanded)), 4096, + "HTML expansion must exceed Telegram limit for this test to be meaningful", + ) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: markdownContent, + }) + + assert.NoError(t, err) + assert.Greater( + t, len(caller.calls), 1, + "markdown-short but HTML-long message should be split into multiple SendMessage calls", + ) +} + +func TestSend_NotRunning(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + t.Fatal("should not be called") + return nil, nil + }, + } + ch := newTestChannel(t, caller) + ch.SetRunning(false) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "12345", + Content: "Hello", + }) + + assert.ErrorIs(t, err, channels.ErrNotRunning) + assert.Empty(t, caller.calls) +} + +func TestSend_InvalidChatID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + t.Fatal("should not be called") + return nil, nil + }, + } + ch := newTestChannel(t, caller) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "not-a-number", + Content: "Hello", + }) + + assert.Error(t, err) + assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed") + assert.Empty(t, caller.calls) +} + +func TestParseTelegramChatID_Plain(t *testing.T) { + cid, tid, err := parseTelegramChatID("12345") + assert.NoError(t, err) + assert.Equal(t, int64(12345), cid) + assert.Equal(t, 0, tid) +} + +func TestParseTelegramChatID_NegativeGroup(t *testing.T) { + cid, tid, err := parseTelegramChatID("-1001234567890") + assert.NoError(t, err) + assert.Equal(t, int64(-1001234567890), cid) + assert.Equal(t, 0, tid) +} + +func TestParseTelegramChatID_WithThreadID(t *testing.T) { + cid, tid, err := parseTelegramChatID("-1001234567890/42") + assert.NoError(t, err) + assert.Equal(t, int64(-1001234567890), cid) + assert.Equal(t, 42, tid) +} + +func TestParseTelegramChatID_GeneralTopic(t *testing.T) { + cid, tid, err := parseTelegramChatID("-100123/1") + assert.NoError(t, err) + assert.Equal(t, int64(-100123), cid) + assert.Equal(t, 1, tid) +} + +func TestParseTelegramChatID_Invalid(t *testing.T) { + _, _, err := parseTelegramChatID("not-a-number") + assert.Error(t, err) +} + +func TestParseTelegramChatID_InvalidThreadID(t *testing.T) { + _, _, err := parseTelegramChatID("-100123/not-a-thread") + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid thread ID") +} + +func TestSend_WithForumThreadID(t *testing.T) { + caller := &stubCaller{ + callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) { + return successResponse(t), nil + }, + } + ch := newTestChannel(t, caller) + + err := ch.Send(context.Background(), bus.OutboundMessage{ + ChatID: "-1001234567890/42", + Content: "Hello from topic", + }) + + assert.NoError(t, err) + assert.Len(t, caller.calls, 1) +} + +func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "hello from topic", + MessageID: 10, + MessageThreadID: 42, + Chat: telego.Chat{ + ID: -1001234567890, + Type: "supergroup", + IsForum: true, + }, + From: &telego.User{ + ID: 7, + FirstName: "Alice", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + require.True(t, ok, "expected inbound message") + + // Composite chatID should include thread ID + assert.Equal(t, "-1001234567890/42", inbound.ChatID) + + // Peer ID should include thread ID for session key isolation + assert.Equal(t, "group", inbound.Peer.Kind) + assert.Equal(t, "-1001234567890/42", inbound.Peer.ID) + + // Parent peer metadata should be set for agent binding + assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"]) + assert.Equal(t, "42", inbound.Metadata["parent_peer_id"]) +} + +func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + msg := &telego.Message{ + Text: "regular group message", + MessageID: 11, + Chat: telego.Chat{ + ID: -100999, + Type: "group", + }, + From: &telego.User{ + ID: 8, + FirstName: "Bob", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + require.True(t, ok) + + // Plain chatID without thread suffix + assert.Equal(t, "-100999", inbound.ChatID) + + // Peer ID should be raw chat ID (no thread suffix) + assert.Equal(t, "group", inbound.Peer.Kind) + assert.Equal(t, "-100999", inbound.Peer.ID) + + // No parent peer metadata + assert.Empty(t, inbound.Metadata["parent_peer_kind"]) + assert.Empty(t, inbound.Metadata["parent_peer_id"]) +} + +func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) { + messageBus := bus.NewMessageBus() + ch := &TelegramChannel{ + BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil), + chatIDs: make(map[string]int64), + ctx: context.Background(), + } + + // In regular groups, reply threads set MessageThreadID to the original + // message ID. This should NOT trigger per-thread session isolation. + msg := &telego.Message{ + Text: "reply in thread", + MessageID: 20, + MessageThreadID: 15, + Chat: telego.Chat{ + ID: -100999, + Type: "supergroup", + IsForum: false, + }, + From: &telego.User{ + ID: 9, + FirstName: "Carol", + }, + } + + err := ch.handleMessage(context.Background(), msg) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + inbound, ok := messageBus.ConsumeInbound(ctx) + require.True(t, ok) + + // chatID should NOT include thread suffix for non-forum groups + assert.Equal(t, "-100999", inbound.ChatID) + + // Peer ID should be raw chat ID (shared session for whole group) + assert.Equal(t, "group", inbound.Peer.Kind) + assert.Equal(t, "-100999", inbound.Peer.ID) + + // No parent peer metadata + assert.Empty(t, inbound.Metadata["parent_peer_kind"]) + assert.Empty(t, inbound.Metadata["parent_peer_id"]) } diff --git a/pkg/channels/wecom/app_test.go b/pkg/channels/wecom/app_test.go index 7f230494f..7d07041ad 100644 --- a/pkg/channels/wecom/app_test.go +++ b/pkg/channels/wecom/app_test.go @@ -209,7 +209,7 @@ func TestWeComAppVerifySignature(t *testing.T) { } }) - t.Run("empty token skips verification", func(t *testing.T) { + t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) { cfgEmpty := config.WeComAppConfig{ CorpID: "test_corp_id", CorpSecret: "test_secret", @@ -218,8 +218,8 @@ func TestWeComAppVerifySignature(t *testing.T) { } chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) - if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { - t.Error("empty token should skip verification and return true") + if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { + t.Error("empty token should reject verification (fail-closed)") } }) } diff --git a/pkg/channels/wecom/bot_test.go b/pkg/channels/wecom/bot_test.go index c053578b1..d223bb6b6 100644 --- a/pkg/channels/wecom/bot_test.go +++ b/pkg/channels/wecom/bot_test.go @@ -189,8 +189,7 @@ func TestWeComBotVerifySignature(t *testing.T) { } }) - t.Run("empty token skips verification", func(t *testing.T) { - // Create a channel manually with empty token to test the behavior + t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) { cfgEmpty := config.WeComConfig{ Token: "", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", @@ -199,8 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) { config: cfgEmpty, } - if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { - t.Error("empty token should skip verification and return true") + if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { + t.Error("empty token should reject verification (fail-closed)") } }) } diff --git a/pkg/channels/wecom/common.go b/pkg/channels/wecom/common.go index 6510e6f81..9a622a2fc 100644 --- a/pkg/channels/wecom/common.go +++ b/pkg/channels/wecom/common.go @@ -31,7 +31,7 @@ func computeSignature(token, timestamp, nonce, encrypt string) string { // This is a common function used by both WeCom Bot and WeCom App func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { if token == "" { - return true // Skip verification if token is not set + return false } return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature } diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index a36dd3eba..aed6a1874 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -12,5 +12,6 @@ func BuiltinDefinitions() []Definition { listCommand(), switchCommand(), checkCommand(), + clearCommand(), } } diff --git a/pkg/commands/cmd_clear.go b/pkg/commands/cmd_clear.go new file mode 100644 index 000000000..f0951eb3b --- /dev/null +++ b/pkg/commands/cmd_clear.go @@ -0,0 +1,20 @@ +package commands + +import "context" + +func clearCommand() Definition { + return Definition{ + Name: "clear", + Description: "Clear the chat history", + Usage: "/clear", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ClearHistory == nil { + return req.Reply(unavailableMsg) + } + if err := rt.ClearHistory(); err != nil { + return req.Reply("Failed to clear chat history: " + err.Error()) + } + return req.Reply("Chat history cleared!") + }, + } +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 227d495f4..037184686 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -13,4 +13,5 @@ type Runtime struct { GetEnabledChannels func() []string SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error + ClearHistory func() error } diff --git a/pkg/config/config.go b/pkg/config/config.go index f219f364d..58b3215cc 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "sync/atomic" "github.com/caarlos0/env/v11" @@ -16,6 +17,8 @@ var rrCounter atomic.Uint64 // FlexibleStringSlice is a []string that also accepts JSON numbers, // so allow_from can contain both "123" and 123. +// It also supports parsing comma-separated strings from environment variables, +// including both English (,) and Chinese (,) commas. type FlexibleStringSlice []string func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { @@ -47,6 +50,30 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { return nil } +// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing. +// It handles comma-separated values with both English (,) and Chinese (,) commas. +func (f *FlexibleStringSlice) UnmarshalText(text []byte) error { + if len(text) == 0 { + *f = nil + return nil + } + + s := string(text) + // Replace Chinese comma with English comma, then split + s = strings.ReplaceAll(s, ",", ",") + parts := strings.Split(s, ",") + + result := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + result = append(result, part) + } + } + *f = result + return nil +} + type Config struct { Agents AgentsConfig `json:"agents"` Bindings []AgentBinding `json:"bindings,omitempty"` @@ -58,6 +85,17 @@ type Config struct { Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` + Voice VoiceConfig `json:"voice"` + // BuildInfo contains build-time version information + BuildInfo BuildInfo `json:"build_info,omitempty"` +} + +// BuildInfo contains build-time version information +type BuildInfo struct { + Version string `json:"version"` + GitCommit string `json:"git_commit"` + BuildTime string `json:"build_time"` + GoVersion string `json:"go_version"` } // MarshalJSON implements custom JSON marshaling for Config @@ -141,7 +179,7 @@ type AgentConfig struct { } type SubagentsConfig struct { - Enabled bool `json:"enabled,omitempty"` + Enabled bool `json:"enabled,omitempty"` // Fork-only: gate orchestration AllowAgents []string `json:"allow_agents,omitempty"` Model *AgentModelConfig `json:"model,omitempty"` } @@ -189,18 +227,18 @@ type AgentDefaults struct { ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` - ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"` PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` - TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"` - Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"` SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` + TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"` + Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"` Routing *RoutingConfig `json:"routing,omitempty"` } @@ -266,19 +304,20 @@ type WhatsAppConfig struct { AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } + type TelegramConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` - Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` - BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` - Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` - WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` + Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` + BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` + Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` + WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"` SubagentThreadID int `json:"subagent_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"` HeartbeatThreadID int `json:"heartbeat_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_HEARTBEAT_THREAD_ID"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"` } type FeishuConfig struct { @@ -320,6 +359,8 @@ type QQConfig struct { AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` + MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"` + SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"` } @@ -344,16 +385,17 @@ type SlackConfig struct { } type MatrixConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` - Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` - UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` - AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` - DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` - JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` - AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` + Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"` + Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"` + UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"` + AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"` + DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"` + JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"` + MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"` + AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` - ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` + ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"` } type LINEConfig struct { @@ -467,6 +509,10 @@ type DevicesConfig struct { MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` } +type VoiceConfig struct { + EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"` +} + type ProvidersConfig struct { Anthropic ProviderConfig `json:"anthropic"` OpenAI OpenAIProviderConfig `json:"openai"` @@ -489,6 +535,8 @@ type ProvidersConfig struct { Qwen ProviderConfig `json:"qwen"` Mistral ProviderConfig `json:"mistral"` Avian ProviderConfig `json:"avian"` + Minimax ProviderConfig `json:"minimax"` + LongCat ProviderConfig `json:"longcat"` } // IsEmpty checks if all provider configs are empty (no API keys or API bases set) @@ -514,7 +562,9 @@ func (p ProvidersConfig) IsEmpty() bool { p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && p.Mistral.APIKey == "" && p.Mistral.APIBase == "" && - p.Avian.APIKey == "" && p.Avian.APIBase == "" + p.Avian.APIKey == "" && p.Avian.APIBase == "" && + p.Minimax.APIKey == "" && p.Minimax.APIBase == "" && + p.LongCat.APIKey == "" && p.LongCat.APIBase == "" } // MarshalJSON implements custom JSON marshaling for ProvidersConfig @@ -560,12 +610,13 @@ type ModelConfig struct { AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers + // Optional optimizations RPM int `json:"rpm,omitempty"` // Requests per minute limit MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") - Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent) RequestTimeout int `json:"request_timeout,omitempty"` ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive + Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent) } // Validate checks if the ModelConfig has all required fields. @@ -584,21 +635,31 @@ type GatewayConfig struct { Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` } +type ToolDiscoveryConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"` + TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"` + MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"` + UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"` + UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"` +} + type ToolConfig struct { Enabled bool `json:"enabled" env:"ENABLED"` } type BraveConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` } type TavilyConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` - BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"` + BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` } type DuckDuckGoConfig struct { @@ -607,9 +668,10 @@ type DuckDuckGoConfig struct { } type PerplexityConfig struct { - Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` - APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` - MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` + APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` } type SearXNGConfig struct { @@ -650,6 +712,7 @@ type CronToolsConfig struct { type ExecConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` + AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"` CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"` CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"` TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) @@ -668,6 +731,11 @@ type MediaCleanupConfig struct { Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` } +type ReadFileToolConfig struct { + Enabled bool `json:"enabled"` + MaxReadFileSize int `json:"max_read_file_size"` +} + type ToolsConfig struct { AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` @@ -684,7 +752,7 @@ type ToolsConfig struct { InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` - ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` + ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` @@ -736,7 +804,8 @@ type MCPServerConfig struct { // MCPConfig defines configuration for all MCP servers type MCPConfig struct { - ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"` + ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"` + Discovery ToolDiscoveryConfig ` json:"discovery"` // Servers is a map of server name to server configuration Servers map[string]MCPServerConfig `json:"servers,omitempty"` } @@ -936,6 +1005,29 @@ func (c *Config) ValidateModelList() error { return nil } +func MergeAPIKeys(apiKey string, apiKeys []string) []string { + seen := make(map[string]struct{}) + var all []string + + if k := strings.TrimSpace(apiKey); k != "" { + if _, exists := seen[k]; !exists { + seen[k] = struct{}{} + all = append(all, k) + } + } + + for _, k := range apiKeys { + if trimmed := strings.TrimSpace(k); trimmed != "" { + if _, exists := seen[trimmed]; !exists { + seen[trimmed] = struct{}{} + all = append(all, trimmed) + } + } + } + + return all +} + func (t *ToolsConfig) IsToolEnabled(name string) bool { switch name { case "web": diff --git a/pkg/config/config_ext_test.go b/pkg/config/config_ext_test.go new file mode 100644 index 000000000..351ce3826 --- /dev/null +++ b/pkg/config/config_ext_test.go @@ -0,0 +1,123 @@ +package config + +import ( + "encoding/json" + "testing" +) + +func TestAgentDefaults_PlanModel_StringParse(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "plan_model": "anthropic/claude-sonnet-4-6", + "plan_model_fallbacks": ["openai/gpt-4o"], + "max_tokens": 8192, + "max_tool_iterations": 20 + } + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" { + t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel) + } + if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 || + cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" { + t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks) + } +} + +func TestAgentConfig_PlanModel_ObjectParse(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "max_tool_iterations": 20 + }, + "list": [ + { + "id": "main", + "plan_model": "anthropic/claude-sonnet-4-6" + }, + { + "id": "advanced", + "plan_model": { + "primary": "anthropic/claude-opus-4", + "fallbacks": ["anthropic/claude-sonnet-4-6"] + } + } + ] + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(cfg.Agents.List) != 2 { + t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List)) + } + + main := cfg.Agents.List[0] + if main.PlanModel == nil || main.PlanModel.Primary != "anthropic/claude-sonnet-4-6" { + t.Errorf("main.PlanModel = %+v, want primary 'anthropic/claude-sonnet-4-6'", main.PlanModel) + } + + adv := cfg.Agents.List[1] + if adv.PlanModel == nil || adv.PlanModel.Primary != "anthropic/claude-opus-4" { + t.Errorf("advanced.PlanModel = %+v, want primary 'anthropic/claude-opus-4'", adv.PlanModel) + } + if len(adv.PlanModel.Fallbacks) != 1 || adv.PlanModel.Fallbacks[0] != "anthropic/claude-sonnet-4-6" { + t.Errorf("advanced.PlanModel.Fallbacks = %v", adv.PlanModel.Fallbacks) + } +} + +func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) { + jsonData := `{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "plan_model": "default-plan-model", + "plan_model_fallbacks": ["default-fallback"], + "max_tokens": 8192, + "max_tool_iterations": 20 + }, + "list": [ + { + "id": "custom", + "plan_model": { + "primary": "custom-plan-model", + "fallbacks": ["custom-fallback"] + } + } + ] + } + }` + + cfg := DefaultConfig() + if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + custom := cfg.Agents.List[0] + if custom.PlanModel == nil || custom.PlanModel.Primary != "custom-plan-model" { + t.Errorf("custom.PlanModel.Primary = %v, want 'custom-plan-model'", custom.PlanModel) + } + if len(custom.PlanModel.Fallbacks) != 1 || custom.PlanModel.Fallbacks[0] != "custom-fallback" { + t.Errorf("custom.PlanModel.Fallbacks = %v, want [custom-fallback]", custom.PlanModel.Fallbacks) + } + + if cfg.Agents.Defaults.PlanModel != "default-plan-model" { + t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel) + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 86155530c..1c93028c7 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -296,7 +296,7 @@ func TestDefaultConfig_WebTools(t *testing.T) { if cfg.Tools.Web.Brave.MaxResults != 5 { t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) } - if cfg.Tools.Web.Brave.APIKey != "" { + if len(cfg.Tools.Web.Brave.APIKeys) != 0 { t.Error("Brave API key should be empty by default") } if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { @@ -384,6 +384,13 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { } } +func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true") + } +} + func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -400,6 +407,22 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { } } +func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("tools.exec.allow_remote should remain true when unset in config file") + } +} + func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "config.json") @@ -416,133 +439,12 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { } } -func TestAgentDefaults_PlanModel_StringParse(t *testing.T) { - jsonData := `{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "plan_model": "anthropic/claude-sonnet-4-6", - "plan_model_fallbacks": ["openai/gpt-4o"], - "max_tokens": 8192, - "max_tool_iterations": 20 - } - } - }` - - cfg := DefaultConfig() - if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" { - t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel) - } - if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 || - cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" { - t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks) - } -} - -func TestAgentConfig_PlanModel_ObjectParse(t *testing.T) { - jsonData := `{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "max_tokens": 8192, - "max_tool_iterations": 20 - }, - "list": [ - { - "id": "main", - "plan_model": "anthropic/claude-sonnet-4-6" - }, - { - "id": "advanced", - "plan_model": { - "primary": "anthropic/claude-opus-4", - "fallbacks": ["anthropic/claude-sonnet-4-6"] - } - } - ] - } - }` - - cfg := DefaultConfig() - if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if len(cfg.Agents.List) != 2 { - t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List)) - } - - // String form - main := cfg.Agents.List[0] - if main.PlanModel == nil || main.PlanModel.Primary != "anthropic/claude-sonnet-4-6" { - t.Errorf("main.PlanModel = %+v, want primary 'anthropic/claude-sonnet-4-6'", main.PlanModel) - } - - // Object form with fallbacks - adv := cfg.Agents.List[1] - if adv.PlanModel == nil || adv.PlanModel.Primary != "anthropic/claude-opus-4" { - t.Errorf("advanced.PlanModel = %+v, want primary 'anthropic/claude-opus-4'", adv.PlanModel) - } - if len(adv.PlanModel.Fallbacks) != 1 || adv.PlanModel.Fallbacks[0] != "anthropic/claude-sonnet-4-6" { - t.Errorf("advanced.PlanModel.Fallbacks = %v", adv.PlanModel.Fallbacks) - } -} - -func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) { - jsonData := `{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", - "plan_model": "default-plan-model", - "plan_model_fallbacks": ["default-fallback"], - "max_tokens": 8192, - "max_tool_iterations": 20 - }, - "list": [ - { - "id": "custom", - "plan_model": { - "primary": "custom-plan-model", - "fallbacks": ["custom-fallback"] - } - } - ] - } - }` - - cfg := DefaultConfig() - if err := json.Unmarshal([]byte(jsonData), cfg); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - // Agent-level plan_model should override defaults - custom := cfg.Agents.List[0] - if custom.PlanModel == nil || custom.PlanModel.Primary != "custom-plan-model" { - t.Errorf("custom.PlanModel.Primary = %v, want 'custom-plan-model'", custom.PlanModel) - } - if len(custom.PlanModel.Fallbacks) != 1 || custom.PlanModel.Fallbacks[0] != "custom-fallback" { - t.Errorf("custom.PlanModel.Fallbacks = %v, want [custom-fallback]", custom.PlanModel.Fallbacks) - } - - // Defaults should still be intact - if cfg.Agents.Defaults.PlanModel != "default-plan-model" { - t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel) - } -} - func TestLoadConfig_WebToolsProxy(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "config.json") configJSON := `{ "agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}}, - "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}], + "model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}], "tools": {"web":{"proxy":"http://127.0.0.1:7890"}} }` if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { @@ -603,3 +505,119 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) { t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want) } } + +// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators +func TestFlexibleStringSlice_UnmarshalText(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "English commas only", + input: "123,456,789", + expected: []string{"123", "456", "789"}, + }, + { + name: "Chinese commas only", + input: "123,456,789", + expected: []string{"123", "456", "789"}, + }, + { + name: "Mixed English and Chinese commas", + input: "123,456,789", + expected: []string{"123", "456", "789"}, + }, + { + name: "Single value", + input: "123", + expected: []string{"123"}, + }, + { + name: "Values with whitespace", + input: " 123 , 456 , 789 ", + expected: []string{"123", "456", "789"}, + }, + { + name: "Empty string", + input: "", + expected: nil, + }, + { + name: "Only commas - English", + input: ",,", + expected: []string{}, + }, + { + name: "Only commas - Chinese", + input: ",,", + expected: []string{}, + }, + { + name: "Mixed commas with empty parts", + input: "123,,456,,789", + expected: []string{"123", "456", "789"}, + }, + { + name: "Complex mixed values", + input: "user1@example.com,user2@test.com, admin@domain.org", + expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var f FlexibleStringSlice + err := f.UnmarshalText([]byte(tt.input)) + if err != nil { + t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err) + } + + if tt.expected == nil { + if f != nil { + t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f) + } + return + } + + if len(f) != len(tt.expected) { + t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected)) + return + } + + for i, v := range tt.expected { + if f[i] != v { + t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v) + } + } + }) + } +} + +// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior +func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) { + t.Run("Empty string returns nil", func(t *testing.T) { + var f FlexibleStringSlice + err := f.UnmarshalText([]byte("")) + if err != nil { + t.Fatalf("UnmarshalText error = %v", err) + } + if f != nil { + t.Errorf("Empty string should return nil, got %v", f) + } + }) + + t.Run("Commas only returns empty slice", func(t *testing.T) { + var f FlexibleStringSlice + err := f.UnmarshalText([]byte(",,,")) + if err != nil { + t.Fatalf("UnmarshalText error = %v", err) + } + if f == nil { + t.Error("Commas only should return empty slice, not nil") + } + if len(f) != 0 { + t.Errorf("Expected empty slice, got %v", f) + } + }) +} diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index b4177f083..2a3e66043 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -34,7 +34,6 @@ func DefaultConfig() *Config { Temperature: nil, // nil means use provider default MaxToolIterations: 50, SummarizeMessageThreshold: 20, - TaskReminderInterval: 5, SummarizeTokenPercent: 75, }, }, @@ -51,12 +50,10 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, }, Telegram: TelegramConfig{ - Enabled: false, - Token: "", - AllowFrom: FlexibleStringSlice{}, - Typing: TypingConfig{Enabled: true}, - SubagentThreadID: 0, - HeartbeatThreadID: 0, + Enabled: false, + Token: "", + AllowFrom: FlexibleStringSlice{}, + Typing: TypingConfig{Enabled: true}, Placeholder: PlaceholderConfig{ Enabled: true, Text: "Thinking... 💭", @@ -83,10 +80,11 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, }, QQ: QQConfig{ - Enabled: false, - AppID: "", - AppSecret: "", - AllowFrom: FlexibleStringSlice{}, + Enabled: false, + AppID: "", + AppSecret: "", + AllowFrom: FlexibleStringSlice{}, + MaxMessageLength: 2000, }, DingTalk: DingTalkConfig{ Enabled: false, @@ -196,8 +194,8 @@ func DefaultConfig() *Config { // OpenAI - https://platform.openai.com/api-keys { - ModelName: "gpt-5.2", - Model: "openai/gpt-5.2", + ModelName: "gpt-5.4", + Model: "openai/gpt-5.4", APIBase: "https://api.openai.com/v1", APIKey: "", }, @@ -258,8 +256,8 @@ func DefaultConfig() *Config { APIKey: "", }, { - ModelName: "openrouter-gpt-5.2", - Model: "openrouter/openai/gpt-5.2", + ModelName: "openrouter-gpt-5.4", + Model: "openrouter/openai/gpt-5.4", APIBase: "https://openrouter.ai/api/v1", APIKey: "", }, @@ -289,6 +287,12 @@ func DefaultConfig() *Config { }, // Volcengine (火山引擎) - https://console.volcengine.com/ark + { + ModelName: "ark-code-latest", + Model: "volcengine/ark-code-latest", + APIBase: "https://ark.cn-beijing.volces.com/api/v3", + APIKey: "", + }, { ModelName: "doubao-pro", Model: "volcengine/doubao-pro-32k", @@ -313,8 +317,8 @@ func DefaultConfig() *Config { // GitHub Copilot - https://github.com/settings/tokens { - ModelName: "copilot-gpt-5.2", - Model: "github-copilot/gpt-5.2", + ModelName: "copilot-gpt-5.4", + Model: "github-copilot/gpt-5.4", APIBase: "http://localhost:4321", AuthMethod: "oauth", }, @@ -349,6 +353,22 @@ func DefaultConfig() *Config { APIKey: "", }, + // Minimax - https://api.minimaxi.com/ + { + ModelName: "MiniMax-M2.5", + Model: "minimax/MiniMax-M2.5", + APIBase: "https://api.minimaxi.com/v1", + APIKey: "", + }, + + // LongCat - https://longcat.chat/platform + { + ModelName: "LongCat-Flash-Thinking", + Model: "longcat/LongCat-Flash-Thinking", + APIBase: "https://api.longcat.chat/openai", + APIKey: "", + }, + // VLLM (local) - http://localhost:8000 { ModelName: "local-model", @@ -378,6 +398,13 @@ func DefaultConfig() *Config { Brave: BraveConfig{ Enabled: false, APIKey: "", + APIKeys: nil, + MaxResults: 5, + }, + Tavily: TavilyConfig{ + Enabled: false, + APIKey: "", + APIKeys: nil, MaxResults: 5, }, DuckDuckGo: DuckDuckGoConfig{ @@ -387,6 +414,7 @@ func DefaultConfig() *Config { Perplexity: PerplexityConfig{ Enabled: false, APIKey: "", + APIKeys: nil, MaxResults: 5, }, SearXNG: SearXNGConfig{ @@ -413,6 +441,7 @@ func DefaultConfig() *Config { Enabled: true, }, EnableDenyPatterns: true, + AllowRemote: true, TimeoutSeconds: 60, }, Skills: SkillsToolsConfig{ @@ -438,6 +467,13 @@ func DefaultConfig() *Config { ToolConfig: ToolConfig{ Enabled: false, }, + Discovery: ToolDiscoveryConfig{ + Enabled: false, + TTL: 5, + MaxSearchResults: 5, + UseBM25: true, + UseRegex: false, + }, Servers: map[string]MCPServerConfig{}, }, AppendFile: ToolConfig{ @@ -461,8 +497,9 @@ func DefaultConfig() *Config { Message: ToolConfig{ Enabled: true, }, - ReadFile: ToolConfig{ - Enabled: true, + ReadFile: ReadFileToolConfig{ + Enabled: true, + MaxReadFileSize: 64 * 1024, // 64KB }, Spawn: ToolConfig{ Enabled: true, @@ -488,5 +525,14 @@ func DefaultConfig() *Config { Enabled: false, MonitorUSB: true, }, + Voice: VoiceConfig{ + EchoTranscription: false, + }, + BuildInfo: BuildInfo{ + Version: Version, + GitCommit: GitCommit, + BuildTime: BuildTime, + GoVersion: GoVersion, + }, } } diff --git a/pkg/config/migration.go b/pkg/config/migration.go index ade9bf677..af6391651 100644 --- a/pkg/config/migration.go +++ b/pkg/config/migration.go @@ -45,7 +45,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { p := cfg.Providers - result := make([]ModelConfig, 0, 20) + var result []ModelConfig // Track if we've applied the legacy model name fix (only for first provider) legacyModelNameApplied := false @@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { } return ModelConfig{ ModelName: "openai", - Model: "openai/gpt-5.2", + Model: "openai/gpt-5.4", APIKey: p.OpenAI.APIKey, APIBase: p.OpenAI.APIBase, Proxy: p.OpenAI.Proxy, @@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { } return ModelConfig{ ModelName: "github-copilot", - Model: "github-copilot/gpt-5.2", + Model: "github-copilot/gpt-5.4", APIBase: p.GitHubCopilot.APIBase, ConnectMode: p.GitHubCopilot.ConnectMode, }, true @@ -407,6 +407,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig { }, true }, }, + { + providerNames: []string{"longcat"}, + protocol: "longcat", + buildConfig: func(p ProvidersConfig) (ModelConfig, bool) { + if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" { + return ModelConfig{}, false + } + return ModelConfig{ + ModelName: "longcat", + Model: "longcat/LongCat-Flash-Thinking", + APIKey: p.LongCat.APIKey, + APIBase: p.LongCat.APIBase, + Proxy: p.LongCat.Proxy, + RequestTimeout: p.LongCat.RequestTimeout, + }, true + }, + }, } // Process each provider migration diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go index d3019aab0..0665ededa 100644 --- a/pkg/config/migration_test.go +++ b/pkg/config/migration_test.go @@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) { if result[0].ModelName != "openai" { t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai") } - if result[0].Model != "openai/gpt-5.2" { - t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2") + if result[0].Model != "openai/gpt-5.4" { + t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4") } if result[0].APIKey != "sk-test-key" { t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key") @@ -162,14 +162,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) { Qwen: ProviderConfig{APIKey: "key17"}, Mistral: ProviderConfig{APIKey: "key18"}, Avian: ProviderConfig{APIKey: "key19"}, + LongCat: ProviderConfig{APIKey: "key-longcat"}, }, } result := ConvertProvidersToModelList(cfg) - // All 21 providers should be converted - if len(result) != 21 { - t.Errorf("len(result) = %d, want 21", len(result)) + // All 22 providers should be converted + if len(result) != 22 { + t.Errorf("len(result) = %d, want 22", len(result)) } } @@ -383,8 +384,8 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes for _, mc := range result { switch mc.ModelName { case "openai": - if mc.Model != "openai/gpt-5.2" { - t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2") + if mc.Model != "openai/gpt-5.4" { + t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4") } case "deepseek": if mc.Model != "deepseek/deepseek-reasoner" { @@ -557,9 +558,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) { // Tests for buildModelWithProtocol helper function func TestBuildModelWithProtocol_NoPrefix(t *testing.T) { - result := buildModelWithProtocol("openai", "gpt-5.2") - if result != "openai/gpt-5.2" { - t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2") + result := buildModelWithProtocol("openai", "gpt-5.4") + if result != "openai/gpt-5.4" { + t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4") } } diff --git a/pkg/config/version.go b/pkg/config/version.go new file mode 100644 index 000000000..b65d3cf33 --- /dev/null +++ b/pkg/config/version.go @@ -0,0 +1,44 @@ +package config + +import ( + "fmt" + "runtime" +) + +// Build-time variables injected via ldflags during build process. +// These are set by the Makefile or .goreleaser.yaml using the -X flag: +// +// -X github.com/sipeed/picoclaw/pkg/config.Version=<version> +// -X github.com/sipeed/picoclaw/pkg/config.GitCommit=<commit> +// -X github.com/sipeed/picoclaw/pkg/config.BuildTime=<timestamp> +// -X github.com/sipeed/picoclaw/pkg/config.GoVersion=<go-version> +var ( + Version = "dev" // Default value when not built with ldflags + GitCommit string // Git commit SHA (short) + BuildTime string // Build timestamp in RFC3339 format + GoVersion string // Go version used for building +) + +// FormatVersion returns the version string with optional git commit +func FormatVersion() string { + v := Version + if GitCommit != "" { + v += fmt.Sprintf(" (git: %s)", GitCommit) + } + return v +} + +// FormatBuildInfo returns build time and go version info +func FormatBuildInfo() (string, string) { + build := BuildTime + goVer := GoVersion + if goVer == "" { + goVer = runtime.Version() + } + return build, goVer +} + +// GetVersion returns the version string +func GetVersion() string { + return Version +} diff --git a/pkg/config/version_test.go b/pkg/config/version_test.go new file mode 100644 index 000000000..34bc906ce --- /dev/null +++ b/pkg/config/version_test.go @@ -0,0 +1,92 @@ +package config + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatVersion_NoGitCommit(t *testing.T) { + oldVersion, oldGit := Version, GitCommit + t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit }) + + Version = "1.2.3" + GitCommit = "" + + assert.Equal(t, "1.2.3", FormatVersion()) +} + +func TestFormatVersion_WithGitCommit(t *testing.T) { + oldVersion, oldGit := Version, GitCommit + t.Cleanup(func() { Version, GitCommit = oldVersion, oldGit }) + + Version = "1.2.3" + GitCommit = "abc123" + + assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion()) +} + +func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) { + oldBuildTime, oldGoVersion := BuildTime, GoVersion + t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) + + BuildTime = "2026-02-20T00:00:00Z" + GoVersion = "go1.23.0" + + build, goVer := FormatBuildInfo() + + assert.Equal(t, BuildTime, build) + assert.Equal(t, GoVersion, goVer) +} + +func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) { + oldBuildTime, oldGoVersion := BuildTime, GoVersion + t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) + + BuildTime = "" + GoVersion = "go1.23.0" + + build, goVer := FormatBuildInfo() + + assert.Empty(t, build) + assert.Equal(t, GoVersion, goVer) +} + +func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) { + oldBuildTime, oldGoVersion := BuildTime, GoVersion + t.Cleanup(func() { BuildTime, GoVersion = oldBuildTime, oldGoVersion }) + + BuildTime = "x" + GoVersion = "" + + build, goVer := FormatBuildInfo() + + assert.Equal(t, "x", build) + assert.Equal(t, runtime.Version(), goVer) +} + +func TestGetVersion(t *testing.T) { + oldVersion := Version + t.Cleanup(func() { Version = oldVersion }) + + Version = "dev" + assert.Equal(t, "dev", GetVersion()) +} + +func TestGetVersion_Custom(t *testing.T) { + oldVersion := Version + t.Cleanup(func() { Version = oldVersion }) + + Version = "v1.0.0" + assert.Equal(t, "v1.0.0", GetVersion()) +} + +func TestVersion_DefaultIsDev(t *testing.T) { + // Reset to default values + oldVersion := Version + Version = "dev" + t.Cleanup(func() { Version = oldVersion }) + + assert.Equal(t, "dev", Version) +} diff --git a/pkg/health/server.go b/pkg/health/server.go index 0a530a3e8..5609ebdf6 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -2,7 +2,6 @@ package health import ( "context" - "crypto/tls" "encoding/json" "fmt" "maps" @@ -13,7 +12,6 @@ import ( type Server struct { server *http.Server - mux *http.ServeMux mu sync.RWMutex ready bool checks map[string]Check @@ -36,7 +34,6 @@ type StatusResponse struct { func NewServer(host string, port int) *Server { mux := http.NewServeMux() s := &Server{ - mux: mux, ready: false, checks: make(map[string]Check), startTime: time.Now(), @@ -49,34 +46,13 @@ func NewServer(host string, port int) *Server { s.server = &http.Server{ Addr: addr, Handler: mux, - ReadTimeout: 30 * time.Second, - WriteTimeout: 30 * time.Second, + ReadTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, } return s } -// Mux returns the underlying ServeMux so additional routes can be registered. -func (s *Server) Mux() *http.ServeMux { - return s.mux -} - -// StartTLS starts the server with TLS using the provided certificate and key files. -func (s *Server) StartTLS(certFile, keyFile string) error { - s.mu.Lock() - s.ready = true - s.mu.Unlock() - - cert, err := tls.LoadX509KeyPair(certFile, keyFile) - if err != nil { - return fmt.Errorf("failed to load TLS cert: %w", err) - } - s.server.TLSConfig = &tls.Config{ - Certificates: []tls.Certificate{cert}, - } - return s.server.ListenAndServeTLS("", "") -} - func (s *Server) Start() error { s.mu.Lock() s.ready = true diff --git a/pkg/health/server_ext.go b/pkg/health/server_ext.go new file mode 100644 index 000000000..8fff4a74c --- /dev/null +++ b/pkg/health/server_ext.go @@ -0,0 +1,28 @@ +package health + +import ( + "crypto/tls" + "fmt" + "net/http" +) + +// Mux returns the underlying ServeMux so additional routes can be registered. +func (s *Server) Mux() *http.ServeMux { + return s.server.Handler.(*http.ServeMux) +} + +// StartTLS starts the server with TLS using the provided certificate and key files. +func (s *Server) StartTLS(certFile, keyFile string) error { + s.mu.Lock() + s.ready = true + s.mu.Unlock() + + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return fmt.Errorf("failed to load TLS cert: %w", err) + } + s.server.TLSConfig = &tls.Config{ + Certificates: []tls.Certificate{cert}, + } + return s.server.ListenAndServeTLS("", "") +} diff --git a/pkg/heartbeat/service_ext_test.go b/pkg/heartbeat/service_ext_test.go new file mode 100644 index 000000000..e96591484 --- /dev/null +++ b/pkg/heartbeat/service_ext_test.go @@ -0,0 +1,104 @@ +package heartbeat + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/tools" +) + +// TestExecuteHeartbeat_NoSendResponse verifies that heartbeat results +// do not trigger sendResponse (dedup: response is included in task status instead). +// TestExecuteHeartbeat_NoSendResponse verifies that heartbeat results +// do not trigger sendResponse (dedup: response is included in task status instead). +func TestExecuteHeartbeat_NoSendResponse(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + hs.stopChan = make(chan struct{}) + + hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + return &tools.ToolResult{ + ForUser: "Task result for user", + ForLLM: "Task result for LLM", + Silent: false, + IsError: false, + Async: false, + } + }) + + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) + + hs.executeHeartbeat() + + hs.mu.RLock() + notified := !hs.lastNotifiedAt.IsZero() + hs.mu.RUnlock() + if !notified { + t.Error("Expected lastNotifiedAt to be set after heartbeat completion") + } +} + +func TestExecuteHeartbeat_TargetPriority_ExplicitTarget(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + hs.stopChan = make(chan struct{}) + hs.SetHeartbeatThreadID(77) + if err := hs.state.SetHeartbeatTarget("slack:C12345/999"); err != nil { + t.Fatalf("SetHeartbeatTarget failed: %v", err) + } + if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil { + t.Fatalf("SetLastHeartbeatTarget failed: %v", err) + } + + var gotChannel, gotChatID string + hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + gotChannel, gotChatID = channel, chatID + return tools.SilentResult("ok") + }) + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) + + hs.executeHeartbeat() + + if gotChannel != "slack" || gotChatID != "C12345/999" { + t.Fatalf("handler target = %s:%s, want slack:C12345/999", gotChannel, gotChatID) + } +} + +func TestExecuteHeartbeat_TargetPriority_TelegramThread(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + hs := NewHeartbeatService(tmpDir, 30, true) + hs.stopChan = make(chan struct{}) + hs.SetHeartbeatThreadID(77) + if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil { + t.Fatalf("SetLastHeartbeatTarget failed: %v", err) + } + + var gotChannel, gotChatID string + hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { + gotChannel, gotChatID = channel, chatID + return tools.SilentResult("ok") + }) + os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) + + hs.executeHeartbeat() + + if gotChannel != "telegram" || gotChatID != "-100500/77" { + t.Fatalf("handler target = %s:%s, want telegram:-100500/77", gotChannel, gotChatID) + } +} diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go index 8b34ebf6c..3b7eeeefb 100644 --- a/pkg/heartbeat/service_test.go +++ b/pkg/heartbeat/service_test.go @@ -184,42 +184,6 @@ func TestLogPath(t *testing.T) { } } -// TestExecuteHeartbeat_NoSendResponse verifies that heartbeat results -// do not trigger sendResponse (dedup: response is included in task status instead). -func TestExecuteHeartbeat_NoSendResponse(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - hs := NewHeartbeatService(tmpDir, 30, true) - hs.stopChan = make(chan struct{}) - - hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - return &tools.ToolResult{ - ForUser: "Task result for user", - ForLLM: "Task result for LLM", - Silent: false, - IsError: false, - Async: false, - } - }) - - os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) - - // Execute heartbeat — since bus is nil, sendResponse would log but not crash. - // The key assertion is that lastNotifiedAt is still updated (flow reaches end). - hs.executeHeartbeat() - - hs.mu.RLock() - notified := !hs.lastNotifiedAt.IsZero() - hs.mu.RUnlock() - if !notified { - t.Error("Expected lastNotifiedAt to be set after heartbeat completion") - } -} - // TestHeartbeatFilePath verifies HEARTBEAT.md is at workspace root func TestHeartbeatFilePath(t *testing.T) { tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") @@ -239,62 +203,3 @@ func TestHeartbeatFilePath(t *testing.T) { t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath) } } - -func TestExecuteHeartbeat_TargetPriority_ExplicitTarget(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - hs := NewHeartbeatService(tmpDir, 30, true) - hs.stopChan = make(chan struct{}) - hs.SetHeartbeatThreadID(77) - if err := hs.state.SetHeartbeatTarget("slack:C12345/999"); err != nil { - t.Fatalf("SetHeartbeatTarget failed: %v", err) - } - if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil { - t.Fatalf("SetLastHeartbeatTarget failed: %v", err) - } - - var gotChannel, gotChatID string - hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - gotChannel, gotChatID = channel, chatID - return tools.SilentResult("ok") - }) - os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) - - hs.executeHeartbeat() - - if gotChannel != "slack" || gotChatID != "C12345/999" { - t.Fatalf("handler target = %s:%s, want slack:C12345/999", gotChannel, gotChatID) - } -} - -func TestExecuteHeartbeat_TargetPriority_TelegramThread(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "heartbeat-test-*") - if err != nil { - t.Fatalf("Failed to create temp dir: %v", err) - } - defer os.RemoveAll(tmpDir) - - hs := NewHeartbeatService(tmpDir, 30, true) - hs.stopChan = make(chan struct{}) - hs.SetHeartbeatThreadID(77) - if err := hs.state.SetLastHeartbeatTarget("telegram:-100500"); err != nil { - t.Fatalf("SetLastHeartbeatTarget failed: %v", err) - } - - var gotChannel, gotChatID string - hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { - gotChannel, gotChatID = channel, chatID - return tools.SilentResult("ok") - }) - os.WriteFile(filepath.Join(tmpDir, "HEARTBEAT.md"), []byte("Test task"), 0o644) - - hs.executeHeartbeat() - - if gotChannel != "telegram" || gotChatID != "-100500/77" { - t.Fatalf("handler target = %s:%s, want telegram:-100500/77", gotChannel, gotChatID) - } -} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 3efa6fd1c..abf00e9b8 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -1,25 +1,26 @@ package logger import ( - "encoding/json" "fmt" - "log" "os" + "path/filepath" "regexp" "runtime" "strings" "sync" "time" + + "github.com/rs/zerolog" ) -type LogLevel int +type LogLevel = zerolog.Level const ( - DEBUG LogLevel = iota - INFO - WARN - ERROR - FATAL + DEBUG = zerolog.DebugLevel + INFO = zerolog.InfoLevel + WARN = zerolog.WarnLevel + ERROR = zerolog.ErrorLevel + FATAL = zerolog.FatalLevel ) var ( @@ -40,7 +41,9 @@ var ( } currentLevel = INFO - logger *Logger + logger zerolog.Logger + fileLogger zerolog.Logger + logFile *os.File once sync.Once mu sync.RWMutex @@ -113,10 +116,6 @@ type LogSubscriber struct { filter func(LogEntry) bool } -type Logger struct { - file *os.File -} - type LogEntry struct { Level string `json:"level"` Timestamp string `json:"timestamp"` @@ -148,7 +147,15 @@ func SanitizeFields(fields map[string]any) map[string]any { func init() { once.Do(func() { - logger = &Logger{} + zerolog.SetGlobalLevel(zerolog.InfoLevel) + + consoleWriter := zerolog.ConsoleWriter{ + Out: os.Stdout, + TimeFormat: "15:04:05", // TODO: make it configurable??? + } + + logger = zerolog.New(consoleWriter).With().Timestamp().Logger() + fileLogger = zerolog.Logger{} ringBuf = newLogRingBuffer(ringBufSize) }) } @@ -157,6 +164,7 @@ func SetLevel(level LogLevel) { mu.Lock() defer mu.Unlock() currentLevel = level + zerolog.SetGlobalLevel(level) } func GetLevel() LogLevel { @@ -169,17 +177,22 @@ func EnableFileLogging(filePath string) error { mu.Lock() defer mu.Unlock() - file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return fmt.Errorf("failed to create log directory: %w", err) + } + + newFile, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) if err != nil { return fmt.Errorf("failed to open log file: %w", err) } - if logger.file != nil { - logger.file.Close() + // Close old file if exists + if logFile != nil { + logFile.Close() } - logger.file = file - log.Println("File logging enabled:", filePath) + logFile = newFile + fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger() return nil } @@ -187,10 +200,57 @@ func DisableFileLogging() { mu.Lock() defer mu.Unlock() - if logger.file != nil { - logger.file.Close() - logger.file = nil - log.Println("File logging disabled") + if logFile != nil { + logFile.Close() + logFile = nil + } + fileLogger = zerolog.Logger{} +} + +func getCallerInfo() (string, int, string) { + for i := 2; i < 15; i++ { + pc, file, line, ok := runtime.Caller(i) + if !ok { + continue + } + + fn := runtime.FuncForPC(pc) + if fn == nil { + continue + } + + // bypass common loggers + if strings.HasSuffix(file, "/logger.go") || + strings.HasSuffix(file, "/log.go") { + continue + } + + funcName := fn.Name() + if strings.HasPrefix(funcName, "runtime.") { + continue + } + + return filepath.Base(file), line, filepath.Base(funcName) + } + + return "???", 0, "???" +} + +//nolint:zerologlint +func getEvent(logger zerolog.Logger, level LogLevel) *zerolog.Event { + switch level { + case zerolog.DebugLevel: + return logger.Debug() + case zerolog.InfoLevel: + return logger.Info() + case zerolog.WarnLevel: + return logger.Warn() + case zerolog.ErrorLevel: + return logger.Error() + case zerolog.FatalLevel: + return logger.Fatal() + default: + return logger.Info() } } @@ -199,6 +259,7 @@ func logMessage(level LogLevel, component string, message string, fields map[str return } + // Build LogEntry for ring buffer and subscribers (fork-only) entry := LogEntry{ Level: logLevelNames[level], Timestamp: time.Now().Format(time.RFC3339), @@ -207,68 +268,46 @@ func logMessage(level LogLevel, component string, message string, fields map[str Fields: fields, } - if pc, file, line, ok := runtime.Caller(2); ok { - fn := runtime.FuncForPC(pc) - if fn != nil { - entry.Caller = fmt.Sprintf("%s:%d (%s)", file, line, fn.Name()) - } - } - // Push to ring buffer and broadcast to subscribers ringBuf.push(entry) broadcastToSubscribers(entry) - if logger.file != nil { - jsonData, err := json.Marshal(entry) - if err == nil { - logger.file.Write(append(jsonData, '\n')) - } - } + // Upstream zerolog console output + callerFile, callerLine, callerFunc := getCallerInfo() - var fieldStr string - if len(fields) > 0 { - fieldStr = " " + formatFields(fields) + event := getEvent(logger, level) + + // Build combined field with component and caller + if component != "" { + event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc)) } else { - fieldStr = "" + event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc)) } - logLine := fmt.Sprintf("[%s] [%s]%s %s%s", - entry.Timestamp, - logLevelNames[level], - formatComponent(component), - message, - fieldStr, - ) + for k, v := range fields { + event.Interface(k, v) + } - log.Println(logLine) + event.Msg(message) + + // Also log to file if enabled + if fileLogger.GetLevel() != zerolog.NoLevel { + fileEvent := getEvent(fileLogger, level) + + if component != "" { + fileEvent.Str("component", component) + } + for k, v := range fields { + fileEvent.Interface(k, v) + } + fileEvent.Msg(message) + } if level == FATAL { os.Exit(1) } } -func formatComponent(component string) string { - if component == "" { - return "" - } - return fmt.Sprintf(" %s:", component) -} - -func formatFields(fields map[string]any) string { - var sb strings.Builder - sb.WriteByte('{') - first := true - for k, v := range fields { - if !first { - sb.WriteString(", ") - } - fmt.Fprintf(&sb, "%s=%v", k, v) - first = false - } - sb.WriteByte('}') - return sb.String() -} - func Debug(message string) { logMessage(DEBUG, "", message, nil) } @@ -341,6 +380,10 @@ func FatalC(component string, message string) { logMessage(FATAL, component, message, nil) } +func Fatalf(message string, ss ...any) { + logMessage(FATAL, "", fmt.Sprintf(message, ss...), nil) +} + func FatalF(message string, fields map[string]any) { logMessage(FATAL, "", message, fields) } diff --git a/pkg/logger/logger_3rd_party.go b/pkg/logger/logger_3rd_party.go new file mode 100644 index 000000000..da50d686a --- /dev/null +++ b/pkg/logger/logger_3rd_party.go @@ -0,0 +1,95 @@ +// this file is for compatible with 3rd party loggers, should not be called in PicoClaw project + +package logger + +import "fmt" + +// Logger implements common Logger interface +type Logger struct { + component string + levels map[int]LogLevel +} + +// Debug logs debug messages +func (b *Logger) Debug(v ...any) { + logMessage(DEBUG, b.component, fmt.Sprint(v...), nil) +} + +// Info logs info messages +func (b *Logger) Info(v ...any) { + logMessage(INFO, b.component, fmt.Sprint(v...), nil) +} + +// Warn logs warning messages +func (b *Logger) Warn(v ...any) { + logMessage(WARN, b.component, fmt.Sprint(v...), nil) +} + +// Error logs error messages +func (b *Logger) Error(v ...any) { + logMessage(ERROR, b.component, fmt.Sprint(v...), nil) +} + +// Debugf logs formatted debug messages +func (b *Logger) Debugf(format string, v ...any) { + logMessage(DEBUG, b.component, fmt.Sprintf(format, v...), nil) +} + +// Infof logs formatted info messages +func (b *Logger) Infof(format string, v ...any) { + logMessage(INFO, b.component, fmt.Sprintf(format, v...), nil) +} + +// Warnf logs formatted warning messages +func (b *Logger) Warnf(format string, v ...any) { + logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil) +} + +// Warningf logs formatted warning messages +func (b *Logger) Warningf(format string, v ...any) { + logMessage(WARN, b.component, fmt.Sprintf(format, v...), nil) +} + +// Errorf logs formatted error messages +func (b *Logger) Errorf(format string, v ...any) { + logMessage(ERROR, b.component, fmt.Sprintf(format, v...), nil) +} + +// Fatalf logs formatted fatal messages and exits +func (b *Logger) Fatalf(format string, v ...any) { + logMessage(FATAL, b.component, fmt.Sprintf(format, v...), nil) +} + +// Log logs a message at a given level with caller information +// the func name must be this because 3rd party loggers expect this +// msgL: message level (DEBUG, INFO, WARN, ERROR, FATAL) +// caller: unused parameter reserved for compatibility +// format: format string +// a: format arguments +// +//nolint:goprintffuncname +func (b *Logger) Log(msgL, caller int, format string, a ...any) { + level := LogLevel(msgL) + if b.levels != nil { + if lvl, ok := b.levels[msgL]; ok { + level = lvl + } + } + logMessage(level, b.component, fmt.Sprintf(format, a...), nil) +} + +// Sync flushes log buffer (no-op for this implementation) +func (b *Logger) Sync() error { + return nil +} + +// WithLevels sets log levels mapping for this logger +func (b *Logger) WithLevels(levels map[int]LogLevel) *Logger { + b.levels = levels + return b +} + +// NewLogger creates a new logger instance with optional component name +func NewLogger(component string) *Logger { + return &Logger{component: component} +} diff --git a/pkg/logger/logger_ext_test.go b/pkg/logger/logger_ext_test.go new file mode 100644 index 000000000..665dcf2f5 --- /dev/null +++ b/pkg/logger/logger_ext_test.go @@ -0,0 +1,289 @@ +package logger + +import ( + "testing" + "time" +) + +func TestRingBuffer_PushAndRecent(t *testing.T) { + rb := newLogRingBuffer(5) + + for i := 0; i < 3; i++ { + rb.push(LogEntry{Message: "msg" + string(rune('A'+i))}) + } + + got := rb.recent(0) + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + if got[0].Message != "msgA" || got[2].Message != "msgC" { + t.Errorf("unexpected order: %v", got) + } +} + +func TestRingBuffer_Wrap(t *testing.T) { + rb := newLogRingBuffer(3) + for i := 0; i < 5; i++ { + rb.push(LogEntry{Message: string(rune('A' + i))}) + } + + got := rb.recent(0) + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + + if got[0].Message != "C" || got[1].Message != "D" || got[2].Message != "E" { + t.Errorf("expected [C,D,E], got [%s,%s,%s]", got[0].Message, got[1].Message, got[2].Message) + } +} + +func TestRingBuffer_RecentLimit(t *testing.T) { + rb := newLogRingBuffer(10) + for i := 0; i < 8; i++ { + rb.push(LogEntry{Message: string(rune('A' + i))}) + } + + got := rb.recent(3) + if len(got) != 3 { + t.Fatalf("expected 3, got %d", len(got)) + } + if got[0].Message != "F" || got[2].Message != "H" { + t.Errorf("expected last 3 entries, got %v", got) + } +} + +func TestRecentLogs_FilterByLevel(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + DebugC("test", "debug msg") + InfoC("test", "info msg") + WarnC("test", "warn msg") + ErrorC("test", "error msg") + + got := RecentLogs(WARN, "", 100) + for _, e := range got { + if e.Level == "DEBUG" || e.Level == "INFO" { + t.Errorf("unexpected level %s in result with minLevel=WARN", e.Level) + } + } +} + +func TestRecentLogs_FilterByComponent(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + InfoC("alpha", "from alpha") + InfoC("beta", "from beta") + InfoC("alpha", "another from alpha") + + got := RecentLogs(DEBUG, "alpha", 100) + for _, e := range got { + if e.Component != "alpha" { + t.Errorf("unexpected component %s in result with component=alpha", e.Component) + } + } +} + +func TestRecentLogs_CallerStripped(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + InfoC("test", "caller test") + + got := RecentLogs(DEBUG, "", 100) + for _, e := range got { + if e.Caller != "" { + t.Errorf("Caller should be stripped, got %q", e.Caller) + } + } +} + +func TestSubscribe_ReceivesEntries(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + sub := Subscribe(nil) + defer Unsubscribe(sub) + + InfoC("sub-test", "hello subscriber") + + select { + case entry := <-sub.Ch: + if entry.Message != "hello subscriber" { + t.Errorf("expected 'hello subscriber', got %q", entry.Message) + } + case <-time.After(time.Second): + t.Error("timed out waiting for log entry") + } +} + +func TestSubscribe_FilterApplied(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + sub := Subscribe(func(e LogEntry) bool { + return e.Component == "target" + }) + defer Unsubscribe(sub) + + InfoC("other", "should be filtered out") + InfoC("target", "should arrive") + + select { + case entry := <-sub.Ch: + if entry.Component != "target" { + t.Errorf("expected component=target, got %q", entry.Component) + } + case <-time.After(time.Second): + t.Error("timed out waiting for filtered entry") + } +} + +func TestUnsubscribe_ClosesChannel(t *testing.T) { + sub := Subscribe(nil) + Unsubscribe(sub) + + _, ok := <-sub.Ch + if ok { + t.Error("expected channel to be closed after Unsubscribe") + } +} + +func TestSanitizeFields(t *testing.T) { + tests := []struct { + name string + input map[string]any + maskedK []string // keys that should be "***" + safeK []string // keys that should keep original value + }{ + { + name: "nil fields", + input: nil, + maskedK: nil, + }, + { + name: "empty fields", + input: map[string]any{}, + maskedK: nil, + }, + { + name: "sensitive keys masked", + input: map[string]any{ + "token": "abc123", + "api_key": "sk-xxx", + "secret": "s3cr3t", + "password": "pass", + "authorization": "Bearer tok", + }, + maskedK: []string{"token", "api_key", "secret", "password", "authorization"}, + }, + { + name: "case insensitive", + input: map[string]any{ + "Token": "abc", + "API_KEY": "xyz", + "Secret": "s", + "PASSWORD": "p", + "Authorization": "a", + "Credential": "c", + }, + maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"}, + }, + { + name: "safe keys preserved", + input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"}, + safeK: []string{"error", "count", "user_id", "component"}, + }, + { + name: "mixed keys", + input: map[string]any{ + "token": "sensitive", + "msg_signature": "safe", + "corp_secret": "sensitive2", + "nonce": "safe2", + }, + maskedK: []string{"token", "corp_secret"}, + safeK: []string{"msg_signature", "nonce"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeFields(tt.input) + for _, k := range tt.maskedK { + if v, ok := result[k]; !ok || v != "***" { + t.Errorf("expected key %q to be masked, got %v", k, v) + } + } + for _, k := range tt.safeK { + if result[k] != tt.input[k] { + t.Errorf("expected key %q to be preserved as %v, got %v", k, tt.input[k], result[k]) + } + } + }) + } +} + +func TestSanitizeFieldsDoesNotMutateOriginal(t *testing.T) { + original := map[string]any{"token": "secret_value", "name": "test"} + _ = SanitizeFields(original) + if original["token"] != "secret_value" { + t.Error("SanitizeFields should not mutate the original map") + } +} + +func TestRecentLogsSanitizesFields(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + InfoCF("sanitize-test", "log with sensitive fields", map[string]any{ + "token": "my-secret-token", + "api_key": "sk-12345", + "user_id": "safe-value", + }) + + got := RecentLogs(DEBUG, "sanitize-test", 100) + if len(got) == 0 { + t.Fatal("expected at least one log entry") + } + + last := got[len(got)-1] + if last.Fields["token"] != "***" { + t.Errorf("expected token to be masked, got %v", last.Fields["token"]) + } + if last.Fields["api_key"] != "***" { + t.Errorf("expected api_key to be masked, got %v", last.Fields["api_key"]) + } + if last.Fields["user_id"] != "safe-value" { + t.Errorf("expected user_id to be preserved, got %v", last.Fields["user_id"]) + } +} + +func TestParseLevel(t *testing.T) { + tests := []struct { + input string + want LogLevel + }{ + {"debug", DEBUG}, + {"DEBUG", DEBUG}, + {"info", INFO}, + {"WARN", WARN}, + {"error", ERROR}, + {"fatal", FATAL}, + {"unknown", INFO}, + {"", INFO}, + } + for _, tt := range tests { + got := ParseLevel(tt.input) + if got != tt.want { + t.Errorf("ParseLevel(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 741b61209..6e6f8dfa8 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -2,7 +2,6 @@ package logger import ( "testing" - "time" ) func TestLogLevelFiltering(t *testing.T) { @@ -138,289 +137,3 @@ func TestLoggerHelperFunctions(t *testing.T) { DebugC("test", "Debug with component") WarnF("Warning with fields", map[string]any{"key": "value"}) } - -// ── Ring buffer tests ── - -func TestRingBuffer_PushAndRecent(t *testing.T) { - rb := newLogRingBuffer(5) - - for i := 0; i < 3; i++ { - rb.push(LogEntry{Message: "msg" + string(rune('A'+i))}) - } - - got := rb.recent(0) - if len(got) != 3 { - t.Fatalf("expected 3, got %d", len(got)) - } - if got[0].Message != "msgA" || got[2].Message != "msgC" { - t.Errorf("unexpected order: %v", got) - } -} - -func TestRingBuffer_Wrap(t *testing.T) { - rb := newLogRingBuffer(3) - for i := 0; i < 5; i++ { - rb.push(LogEntry{Message: string(rune('A' + i))}) - } - - got := rb.recent(0) - if len(got) != 3 { - t.Fatalf("expected 3, got %d", len(got)) - } - // Should have C, D, E (oldest two dropped) - if got[0].Message != "C" || got[1].Message != "D" || got[2].Message != "E" { - t.Errorf("expected [C,D,E], got [%s,%s,%s]", got[0].Message, got[1].Message, got[2].Message) - } -} - -func TestRingBuffer_RecentLimit(t *testing.T) { - rb := newLogRingBuffer(10) - for i := 0; i < 8; i++ { - rb.push(LogEntry{Message: string(rune('A' + i))}) - } - - got := rb.recent(3) - if len(got) != 3 { - t.Fatalf("expected 3, got %d", len(got)) - } - if got[0].Message != "F" || got[2].Message != "H" { - t.Errorf("expected last 3 entries, got %v", got) - } -} - -func TestRecentLogs_FilterByLevel(t *testing.T) { - initialLevel := GetLevel() - defer SetLevel(initialLevel) - SetLevel(DEBUG) - - // Log messages at different levels - DebugC("test", "debug msg") - InfoC("test", "info msg") - WarnC("test", "warn msg") - ErrorC("test", "error msg") - - got := RecentLogs(WARN, "", 100) - for _, e := range got { - if e.Level == "DEBUG" || e.Level == "INFO" { - t.Errorf("unexpected level %s in result with minLevel=WARN", e.Level) - } - } -} - -func TestRecentLogs_FilterByComponent(t *testing.T) { - initialLevel := GetLevel() - defer SetLevel(initialLevel) - SetLevel(DEBUG) - - InfoC("alpha", "from alpha") - InfoC("beta", "from beta") - InfoC("alpha", "another from alpha") - - got := RecentLogs(DEBUG, "alpha", 100) - for _, e := range got { - if e.Component != "alpha" { - t.Errorf("unexpected component %s in result with component=alpha", e.Component) - } - } -} - -func TestRecentLogs_CallerStripped(t *testing.T) { - initialLevel := GetLevel() - defer SetLevel(initialLevel) - SetLevel(DEBUG) - - InfoC("test", "caller test") - - got := RecentLogs(DEBUG, "", 100) - for _, e := range got { - if e.Caller != "" { - t.Errorf("Caller should be stripped, got %q", e.Caller) - } - } -} - -func TestSubscribe_ReceivesEntries(t *testing.T) { - initialLevel := GetLevel() - defer SetLevel(initialLevel) - SetLevel(DEBUG) - - sub := Subscribe(nil) - defer Unsubscribe(sub) - - InfoC("sub-test", "hello subscriber") - - select { - case entry := <-sub.Ch: - if entry.Message != "hello subscriber" { - t.Errorf("expected 'hello subscriber', got %q", entry.Message) - } - case <-time.After(time.Second): - t.Error("timed out waiting for log entry") - } -} - -func TestSubscribe_FilterApplied(t *testing.T) { - initialLevel := GetLevel() - defer SetLevel(initialLevel) - SetLevel(DEBUG) - - sub := Subscribe(func(e LogEntry) bool { - return e.Component == "target" - }) - defer Unsubscribe(sub) - - InfoC("other", "should be filtered out") - InfoC("target", "should arrive") - - select { - case entry := <-sub.Ch: - if entry.Component != "target" { - t.Errorf("expected component=target, got %q", entry.Component) - } - case <-time.After(time.Second): - t.Error("timed out waiting for filtered entry") - } -} - -func TestUnsubscribe_ClosesChannel(t *testing.T) { - sub := Subscribe(nil) - Unsubscribe(sub) - - _, ok := <-sub.Ch - if ok { - t.Error("expected channel to be closed after Unsubscribe") - } -} - -func TestSanitizeFields(t *testing.T) { - tests := []struct { - name string - input map[string]any - maskedK []string // keys that should be "***" - safeK []string // keys that should keep original value - }{ - { - name: "nil fields", - input: nil, - maskedK: nil, - }, - { - name: "empty fields", - input: map[string]any{}, - maskedK: nil, - }, - { - name: "sensitive keys masked", - input: map[string]any{ - "token": "abc123", - "api_key": "sk-xxx", - "secret": "s3cr3t", - "password": "pass", - "authorization": "Bearer tok", - }, - maskedK: []string{"token", "api_key", "secret", "password", "authorization"}, - }, - { - name: "case insensitive", - input: map[string]any{ - "Token": "abc", - "API_KEY": "xyz", - "Secret": "s", - "PASSWORD": "p", - "Authorization": "a", - "Credential": "c", - }, - maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"}, - }, - { - name: "safe keys preserved", - input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"}, - safeK: []string{"error", "count", "user_id", "component"}, - }, - { - name: "mixed keys", - input: map[string]any{ - "token": "sensitive", - "msg_signature": "safe", - "corp_secret": "sensitive2", - "nonce": "safe2", - }, - maskedK: []string{"token", "corp_secret"}, - safeK: []string{"msg_signature", "nonce"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := SanitizeFields(tt.input) - for _, k := range tt.maskedK { - if v, ok := result[k]; !ok || v != "***" { - t.Errorf("expected key %q to be masked, got %v", k, v) - } - } - for _, k := range tt.safeK { - if result[k] != tt.input[k] { - t.Errorf("expected key %q to be preserved as %v, got %v", k, tt.input[k], result[k]) - } - } - }) - } -} - -func TestSanitizeFieldsDoesNotMutateOriginal(t *testing.T) { - original := map[string]any{"token": "secret_value", "name": "test"} - _ = SanitizeFields(original) - if original["token"] != "secret_value" { - t.Error("SanitizeFields should not mutate the original map") - } -} - -func TestRecentLogsSanitizesFields(t *testing.T) { - initialLevel := GetLevel() - defer SetLevel(initialLevel) - SetLevel(DEBUG) - - InfoCF("sanitize-test", "log with sensitive fields", map[string]any{ - "token": "my-secret-token", - "api_key": "sk-12345", - "user_id": "safe-value", - }) - - got := RecentLogs(DEBUG, "sanitize-test", 100) - if len(got) == 0 { - t.Fatal("expected at least one log entry") - } - - last := got[len(got)-1] - if last.Fields["token"] != "***" { - t.Errorf("expected token to be masked, got %v", last.Fields["token"]) - } - if last.Fields["api_key"] != "***" { - t.Errorf("expected api_key to be masked, got %v", last.Fields["api_key"]) - } - if last.Fields["user_id"] != "safe-value" { - t.Errorf("expected user_id to be preserved, got %v", last.Fields["user_id"]) - } -} - -func TestParseLevel(t *testing.T) { - tests := []struct { - input string - want LogLevel - }{ - {"debug", DEBUG}, - {"DEBUG", DEBUG}, - {"info", INFO}, - {"WARN", WARN}, - {"error", ERROR}, - {"fatal", FATAL}, - {"unknown", INFO}, - {"", INFO}, - } - for _, tt := range tests { - got := ParseLevel(tt.input) - if got != tt.want { - t.Errorf("ParseLevel(%q) = %d, want %d", tt.input, got, tt.want) - } - } -} diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go index e12e2c5ab..afe374166 100644 --- a/pkg/memory/jsonl.go +++ b/pkg/memory/jsonl.go @@ -86,14 +86,14 @@ func (s *JSONLStore) metaPath(key string) string { // sanitizeKey converts a session key to a safe filename component. // Mirrors pkg/session.sanitizeFilename so that migration paths match. -// -// Note: this is a lossy mapping — "telegram:123" and "telegram_123" -// both produce the same filename. This is an intentional tradeoff: -// keys with colons (e.g. from channels) are by far the common case, -// and a bidirectional encoding (like URL-encoding) would complicate -// file listings and debugging. +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' +// so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts") +// do not create subdirectories or break on Windows. func sanitizeKey(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } // readMeta loads the metadata file for a session. diff --git a/pkg/memory/jsonl_test.go b/pkg/memory/jsonl_test.go index 356ff14ff..31af5f320 100644 --- a/pkg/memory/jsonl_test.go +++ b/pkg/memory/jsonl_test.go @@ -96,7 +96,7 @@ func TestAddFullMessage_WithToolCalls(t *testing.T) { Type: "function", Function: &providers.FunctionCall{ Name: "web_search", - Arguments: `{"q":"golang jsonl"}`, + Arguments: map[string]any{"q": "golang jsonl"}, }, }, }, diff --git a/pkg/memory/migration.go b/pkg/memory/migration.go index c9d5176ab..b64c62a9f 100644 --- a/pkg/memory/migration.go +++ b/pkg/memory/migration.go @@ -48,6 +48,12 @@ func MigrateFromJSON( if !strings.HasSuffix(name, ".json") { continue } + // Skip JSONL metadata files. They are part of the new storage format, + // not legacy session snapshots, and re-importing them would overwrite + // the paired .jsonl history with an empty message list. + if strings.HasSuffix(name, ".meta.json") { + continue + } // Skip already-migrated files. if strings.HasSuffix(name, ".migrated") { continue diff --git a/pkg/memory/migration_test.go b/pkg/memory/migration_test.go index 3170758b7..cc6d05da2 100644 --- a/pkg/memory/migration_test.go +++ b/pkg/memory/migration_test.go @@ -86,7 +86,7 @@ func TestMigrateFromJSON_WithToolCalls(t *testing.T) { Type: "function", Function: &providers.FunctionCall{ Name: "web_search", - Arguments: `{"q":"test"}`, + Arguments: map[string]any{"q": "test"}, }, }, }, @@ -382,3 +382,55 @@ func TestMigrateFromJSON_NonexistentDir(t *testing.T) { t.Errorf("expected 0, got %d", count) } } + +func TestMigrateFromJSON_SkipsMetaJSONFiles(t *testing.T) { + sessionsDir := t.TempDir() + store, err := NewJSONLStore(sessionsDir) + if err != nil { + t.Fatalf("NewJSONLStore: %v", err) + } + ctx := context.Background() + + if addErr := store.AddMessage(ctx, "agent:main:pico:direct:pico:test", "user", "keep me"); addErr != nil { + t.Fatalf("AddMessage: %v", addErr) + } + if summaryErr := store.SetSummary(ctx, "agent:main:pico:direct:pico:test", "keep summary"); summaryErr != nil { + t.Fatalf("SetSummary: %v", summaryErr) + } + + metaPath := filepath.Join(sessionsDir, "agent_main_pico_direct_pico_test.meta.json") + if _, statErr := os.Stat(metaPath); statErr != nil { + t.Fatalf("meta file missing before migration: %v", statErr) + } + + count, err := MigrateFromJSON(ctx, sessionsDir, store) + if err != nil { + t.Fatalf("MigrateFromJSON: %v", err) + } + if count != 0 { + t.Fatalf("expected 0 migrated, got %d", count) + } + + history, err := store.GetHistory(ctx, "agent:main:pico:direct:pico:test") + if err != nil { + t.Fatalf("GetHistory: %v", err) + } + if len(history) != 1 || history[0].Content != "keep me" { + t.Fatalf("history = %+v, want preserved single message", history) + } + + summary, err := store.GetSummary(ctx, "agent:main:pico:direct:pico:test") + if err != nil { + t.Fatalf("GetSummary: %v", err) + } + if summary != "keep summary" { + t.Fatalf("summary = %q, want %q", summary, "keep summary") + } + + if _, statErr := os.Stat(metaPath); statErr != nil { + t.Fatalf("meta file should remain in place: %v", statErr) + } + if _, statErr := os.Stat(metaPath + ".migrated"); !os.IsNotExist(statErr) { + t.Fatalf("meta file should not be renamed, stat err = %v", statErr) + } +} diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go index d57dbe34f..7cbd2d1e6 100644 --- a/pkg/migrate/sources/openclaw/common.go +++ b/pkg/migrate/sources/openclaw/common.go @@ -4,8 +4,8 @@ var migrateableFiles = []string{ "AGENTS.md", "SOUL.md", "USER.md", - "TOOLS.md", "HEARTBEAT.md", + "TOOLS.md", } var migrateableDirs = []string{ diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index 19d63bb77..e95c2f3ec 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -733,16 +733,18 @@ type WebToolsConfig struct { } type BraveConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + MaxResults int `json:"max_results"` } type TavilyConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - BaseURL string `json:"base_url"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + BaseURL string `json:"base_url"` + MaxResults int `json:"max_results"` } type DuckDuckGoConfig struct { @@ -751,9 +753,10 @@ type DuckDuckGoConfig struct { } type PerplexityConfig struct { - Enabled bool `json:"enabled"` - APIKey string `json:"api_key"` - MaxResults int `json:"max_results"` + Enabled bool `json:"enabled"` + APIKey string `json:"api_key"` + APIKeys []string `json:"api_keys"` + MaxResults int `json:"max_results"` } type CronConfig struct { @@ -1082,6 +1085,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig { Brave: config.BraveConfig{ Enabled: c.Web.Brave.Enabled, APIKey: c.Web.Brave.APIKey, + APIKeys: c.Web.Brave.APIKeys, MaxResults: c.Web.Brave.MaxResults, }, Tavily: config.TavilyConfig{ @@ -1107,6 +1111,7 @@ func (c ToolsConfig) ToStandardTools() config.ToolsConfig { Exec: config.ExecConfig{ EnableDenyPatterns: c.Exec.EnableDenyPatterns, CustomDenyPatterns: c.Exec.CustomDenyPatterns, + AllowRemote: config.DefaultConfig().Tools.Exec.AllowRemote, }, } } diff --git a/pkg/migrate/sources/openclaw/openclaw_config_test.go b/pkg/migrate/sources/openclaw/openclaw_config_test.go index 3a7d0c686..802693825 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config_test.go +++ b/pkg/migrate/sources/openclaw/openclaw_config_test.go @@ -290,6 +290,20 @@ func TestConvertToPicoClaw(t *testing.T) { } } +func TestToStandardConfig_ExecAllowRemoteDefaultsTrue(t *testing.T) { + cfg := (&PicoClawConfig{ + Tools: ToolsConfig{ + Exec: ExecConfig{ + EnableDenyPatterns: true, + }, + }, + }).ToStandardConfig() + + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("ToStandardConfig() should preserve the default tools.exec.allow_remote=true") + } +} + func TestConvertToPicoClawWithQQAndDingTalk(t *testing.T) { tmpDir := t.TempDir() configPath := filepath.Join(tmpDir, "openclaw.json") diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index 6e5260039..17d70314a 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -181,10 +181,8 @@ func buildParams( } for _, tc := range msg.ToolCalls { args := tc.Arguments - if args == nil && tc.Function != nil && tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil { - args = map[string]any{} - } + if args == nil && tc.Function != nil && len(tc.Function.Arguments) > 0 { + args = tc.Function.Arguments } if args == nil { args = map[string]any{} @@ -318,9 +316,7 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { if desc := t.Function.Description; desc != "" { tool.Description = anthropic.String(desc) } - - switch req := params["required"].(type) { - case []any: + if req, ok := params["required"].([]any); ok { required := make([]string, 0, len(req)) for _, r := range req { if s, ok := r.(string); ok { @@ -328,10 +324,7 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { } } tool.InputSchema.Required = required - case []string: - tool.InputSchema.Required = append([]string(nil), req...) } - result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) } return result diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go index 2c8a8e6bf..dc45441ab 100644 --- a/pkg/providers/anthropic/provider_test.go +++ b/pkg/providers/anthropic/provider_test.go @@ -9,8 +9,6 @@ import ( "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" - - "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) func TestBuildParams_BasicMessage(t *testing.T) { @@ -86,13 +84,9 @@ func TestBuildParams_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather for a city", - Parameters: protocoltypes.MustMarshalParameters(map[string]any{ - "type": "object", - "properties": map[string]any{ - "city": map[string]any{"type": "string"}, - }, - "required": []any{"city"}, - }), + Parameters: json.RawMessage( + `{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}`, + ), }, }, } diff --git a/pkg/providers/antigravity_provider.go b/pkg/providers/antigravity_provider.go index 5e17e0853..7cd530977 100644 --- a/pkg/providers/antigravity_provider.go +++ b/pkg/providers/antigravity_provider.go @@ -340,13 +340,14 @@ func normalizeStoredToolCall(tc ToolCall) (string, map[string]any, string) { thoughtSignature = tc.Function.ThoughtSignature } - if len(args) == 0 && tc.Function != nil && len(tc.Function.Arguments) > 0 { - args = cloneToolArgs(tc.Function.Arguments) - } if args == nil { args = map[string]any{} } + if len(args) == 0 && tc.Function != nil && len(tc.Function.Arguments) > 0 { + args = tc.Function.Arguments + } + return name, args, thoughtSignature } diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go index dcfec73b9..6c4f6a767 100644 --- a/pkg/providers/claude_cli_provider.go +++ b/pkg/providers/claude_cli_provider.go @@ -100,45 +100,12 @@ func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDe } if len(tools) > 0 { - parts = append(parts, p.buildToolsPrompt(tools)) + parts = append(parts, buildCLIToolsPrompt(tools)) } return strings.Join(parts, "\n\n") } -// buildToolsPrompt creates the tool definitions section for the system prompt. -func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - var sb strings.Builder - - sb.WriteString("## Available Tools\n\n") - sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") - sb.WriteString("```json\n") - sb.WriteString( - `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, - ) - sb.WriteString("\n```\n\n") - sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") - sb.WriteString("### Tool Definitions:\n\n") - - for _, tool := range tools { - if tool.Type != "function" { - continue - } - sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) - if tool.Function.Description != "" { - sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) - } - if len(tool.Function.Parameters) > 0 { - sb.WriteString("Parameters:\n```json\n") - sb.Write(tool.Function.Parameters) - sb.WriteString("\n```\n") - } - sb.WriteString("\n") - } - - return sb.String() -} - // parseClaudeCliResponse parses the JSON output from the claude CLI. func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) { var resp claudeCliJSONResponse diff --git a/pkg/providers/claude_cli_provider_ext_test.go b/pkg/providers/claude_cli_provider_ext_test.go new file mode 100644 index 000000000..c50731034 --- /dev/null +++ b/pkg/providers/claude_cli_provider_ext_test.go @@ -0,0 +1,286 @@ +package providers + +import ( + "strings" + "testing" +) + +func TestExtractXMLToolCalls_Single(t *testing.T) { + text := `<vendor:toolcall> +<invoke name="exec"> +<parameter name="command">echo hello</parameter> +</invoke> +</vendor:toolcall>` + + calls := extractXMLToolCalls(text) + if len(calls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(calls)) + } + if calls[0].Name != "exec" { + t.Errorf("Name = %q, want %q", calls[0].Name, "exec") + } + if calls[0].Arguments["command"] != "echo hello" { + t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "echo hello") + } + if calls[0].Function == nil || calls[0].Function.Name != "exec" { + t.Errorf("Function.Name should be exec") + } +} + +func TestExtractXMLToolCalls_Multiple(t *testing.T) { + text := `<vendor:toolcall> +<invoke name="web_search"> +<parameter name="query">golang testing</parameter> +</invoke> +<invoke name="exec"> +<parameter name="command">go test ./...</parameter> +<parameter name="timeout">30</parameter> +</invoke> +</vendor:toolcall>` + + calls := extractXMLToolCalls(text) + if len(calls) != 2 { + t.Fatalf("expected 2 tool calls, got %d", len(calls)) + } + if calls[0].Name != "web_search" { + t.Errorf("[0].Name = %q, want %q", calls[0].Name, "web_search") + } + if calls[1].Name != "exec" { + t.Errorf("[1].Name = %q, want %q", calls[1].Name, "exec") + } + if calls[1].Arguments["timeout"] != "30" { + t.Errorf("[1].Arguments[timeout] = %v, want %q", calls[1].Arguments["timeout"], "30") + } +} + +func TestExtractXMLToolCalls_NoXML(t *testing.T) { + calls := extractXMLToolCalls("just regular text") + if len(calls) != 0 { + t.Errorf("expected 0 tool calls, got %d", len(calls)) + } +} + +func TestStripXMLToolCalls(t *testing.T) { + text := `Let me run that. +<vendor:toolcall> +<invoke name="exec"> +<parameter name="command">echo hello</parameter> +</invoke> +</vendor:toolcall> +Done.` + + got := stripXMLToolCalls(text) + if strings.Contains(got, "toolcall") { + t.Errorf("should remove XML block, got %q", got) + } + if !strings.Contains(got, "Let me run that.") { + t.Errorf("should keep text before, got %q", got) + } + if !strings.Contains(got, "Done.") { + t.Errorf("should keep text after, got %q", got) + } +} + +func TestExtractXMLToolCalls_MismatchedCloseTag(t *testing.T) { + text := `<minimax:toolcall> +<invoke name="readfile"> +<parameter name="path">/home/user/project/pyproject.toml</parameter> +</invoke> +</minimax:tool_call>` + + calls := extractXMLToolCalls(text) + if len(calls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(calls)) + } + if calls[0].Name != "readfile" { + t.Errorf("Name = %q, want %q", calls[0].Name, "readfile") + } + if calls[0].Arguments["path"] != "/home/user/project/pyproject.toml" { + t.Errorf("Arguments[path] = %v, want pyproject.toml path", calls[0].Arguments["path"]) + } +} + +func TestStripXMLToolCalls_MismatchedCloseTag(t *testing.T) { + text := `今テスト走らせるね。` + //nolint:gosmopolitan + ` +<minimax:toolcall> +<invoke name="exec"> +<parameter name="command">cd /home/user && pytest</parameter> +</invoke> +</minimax:tool_call>` + + got := stripXMLToolCalls(text) + if strings.Contains(got, "toolcall") || strings.Contains(got, "tool_call") { + t.Errorf("should remove XML block, got %q", got) + } + if !strings.Contains(got, "今テスト走らせるね。") { //nolint:gosmopolitan + t.Errorf("should keep text before, got %q", got) + } +} + +func TestExtractXMLToolCalls_UnderscoreOpenTag(t *testing.T) { + text := `<minimax:tool_call> +<invoke name="exec"> +<parameter name="command">ls -la</parameter> +</invoke> +</minimax:tool_call>` + + calls := extractXMLToolCalls(text) + if len(calls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(calls)) + } + if calls[0].Name != "exec" { + t.Errorf("Name = %q, want %q", calls[0].Name, "exec") + } + if calls[0].Arguments["command"] != "ls -la" { + t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "ls -la") + } +} + +func TestExtractXMLToolCalls_HyphenTag(t *testing.T) { + text := `<vendor:Tool-Call> +<invoke name="read_file"> +<parameter name="path">/etc/hosts</parameter> +</invoke> +</vendor:tool-call>` + + calls := extractXMLToolCalls(text) + if len(calls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(calls)) + } + if calls[0].Name != "read_file" { + t.Errorf("Name = %q, want %q", calls[0].Name, "read_file") + } +} + +func TestStripXMLToolCalls_UnderscoreOpenTag(t *testing.T) { + text := `Here is the result. +<minimax:tool_call> +<invoke name="exec"> +<parameter name="command">ls</parameter> +</invoke> +</minimax:toolcall> +Finished.` + + got := stripXMLToolCalls(text) + if strings.Contains(got, "tool_call") || strings.Contains(got, "toolcall") { + t.Errorf("should remove XML block, got %q", got) + } + if !strings.Contains(got, "Here is the result.") { + t.Errorf("should keep text before, got %q", got) + } + if !strings.Contains(got, "Finished.") { + t.Errorf("should keep text after, got %q", got) + } +} + +func TestExtractXMLToolCalls_OrphanedClosingTag(t *testing.T) { + //nolint:gosmopolitan // intentional CJK test fixture + text := "了解!確認するね。\n[TOOLCALL]\n" + + "<invoke name=\"listdir\">\n" + + "<parameter name=\"path\">/home/user/workspace</parameter>\n" + + "</invoke>\n</minimax:tool_call>" + + calls := extractXMLToolCalls(text) + if len(calls) != 1 { + t.Fatalf("expected 1 tool call, got %d", len(calls)) + } + if calls[0].Name != "listdir" { + t.Errorf("Name = %q, want %q", calls[0].Name, "listdir") + } + if calls[0].Arguments["path"] != "/home/user/workspace" { + t.Errorf("Arguments[path] = %v, want /home/user/workspace", calls[0].Arguments["path"]) + } +} + +func TestStripXMLToolCalls_OrphanedClosingTag(t *testing.T) { + //nolint:gosmopolitan // intentional CJK test fixture + text := "了解!確認するね。\n[TOOLCALL]\n" + + "<invoke name=\"listdir\">\n" + + "<parameter name=\"path\">/home/user</parameter>\n" + + "</invoke>\n</minimax:tool_call>" + got := stripXMLToolCalls(text) + if strings.Contains(got, "invoke") || strings.Contains(got, "TOOLCALL") || strings.Contains(got, "minimax") { + t.Errorf("should remove orphaned closing tag block, got %q", got) + } + if !strings.Contains(got, "了解") { //nolint:gosmopolitan + t.Errorf("should keep user-facing text, got %q", got) + } +} + +func TestStripXMLToolCalls_NoXML(t *testing.T) { + text := "Just regular text." + got := stripXMLToolCalls(text) + if got != text { + t.Errorf("stripXMLToolCalls() = %q, want %q", got, text) + } +} + +func TestNormalizeAlpha(t *testing.T) { + tests := []struct { + input, want string + }{ + {"toolcall", "toolcall"}, + {"tool_call", "toolcall"}, + {"Tool-Call", "toolcall"}, + {"ReadFile", "readfile"}, + {"read_file", "readfile"}, + {"EXEC", "exec"}, + {"web123search", "websearch"}, + {"", ""}, + } + for _, tt := range tests { + got := normalizeAlpha(tt.input) + if got != tt.want { + t.Errorf("normalizeAlpha(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestLevenshtein(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"abc", "", 3}, + {"", "abc", 3}, + {"toolcall", "toolcall", 0}, + {"toolcall", "tool_call", 1}, + {"toolcall", "tool-call", 1}, + {"toolcall", "ToolCall", 2}, + {"kitten", "sitting", 3}, + } + for _, tt := range tests { + got := levenshtein(tt.a, tt.b) + if got != tt.want { + t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + } +} + +func TestIsToolCallTag(t *testing.T) { + for _, name := range []string{"toolcall", "tool_call", "tool-call", "ToolCall", "Toolcall", "toolCall", "TOOLCALL"} { + if !isToolCallTag(name) { + t.Errorf("isToolCallTag(%q) = false, want true", name) + } + } + + for _, name := range []string{"function_call", "FunctionCall", "functioncall", "FUNCTION_CALL"} { + if !isToolCallTag(name) { + t.Errorf("isToolCallTag(%q) = false, want true", name) + } + } + + for _, name := range []string{"tool_use", "ToolUse", "tooluse", "TOOL_USE"} { + if !isToolCallTag(name) { + t.Errorf("isToolCallTag(%q) = false, want true", name) + } + } + + for _, name := range []string{"invoke", "parameter", "function", "result", "hello", "content"} { + if isToolCallTag(name) { + t.Errorf("isToolCallTag(%q) = true, want false", name) + } + } +} diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go index 11c33ab2c..2f40e2e1a 100644 --- a/pkg/providers/claude_cli_provider_test.go +++ b/pkg/providers/claude_cli_provider_test.go @@ -2,6 +2,7 @@ package providers import ( "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -619,12 +620,7 @@ func TestBuildSystemPrompt_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather for a location", - Parameters: MustMarshalParameters(map[string]any{ - "type": "object", - "properties": map[string]any{ - "location": map[string]any{"type": "string"}, - }, - }), + Parameters: json.RawMessage(`{"type":"object","properties":{"location":{"type":"string"}}}`), }, }, } @@ -917,9 +913,9 @@ func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) { if got[0].Arguments["name"] != "test" { t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"]) } - // Verify parsed arguments are also set on FunctionCall + // Verify raw arguments string is preserved in FunctionCall if len(got[0].Function.Arguments) == 0 { - t.Error("Function.Arguments should contain parsed JSON arguments") + t.Error("Function.Arguments should contain parsed arguments") } } @@ -984,282 +980,3 @@ func TestFindMatchingBrace(t *testing.T) { } } } - -// --- XML tool call extract/strip tests --- - -func TestExtractXMLToolCalls_Single(t *testing.T) { - text := `<vendor:toolcall> -<invoke name="exec"> -<parameter name="command">echo hello</parameter> -</invoke> -</vendor:toolcall>` - - calls := extractXMLToolCalls(text) - if len(calls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(calls)) - } - if calls[0].Name != "exec" { - t.Errorf("Name = %q, want %q", calls[0].Name, "exec") - } - if calls[0].Arguments["command"] != "echo hello" { - t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "echo hello") - } - if calls[0].Function == nil || calls[0].Function.Name != "exec" { - t.Errorf("Function.Name should be exec") - } -} - -func TestExtractXMLToolCalls_Multiple(t *testing.T) { - text := `<vendor:toolcall> -<invoke name="web_search"> -<parameter name="query">golang testing</parameter> -</invoke> -<invoke name="exec"> -<parameter name="command">go test ./...</parameter> -<parameter name="timeout">30</parameter> -</invoke> -</vendor:toolcall>` - - calls := extractXMLToolCalls(text) - if len(calls) != 2 { - t.Fatalf("expected 2 tool calls, got %d", len(calls)) - } - if calls[0].Name != "web_search" { - t.Errorf("[0].Name = %q, want %q", calls[0].Name, "web_search") - } - if calls[1].Name != "exec" { - t.Errorf("[1].Name = %q, want %q", calls[1].Name, "exec") - } - if calls[1].Arguments["timeout"] != "30" { - t.Errorf("[1].Arguments[timeout] = %v, want %q", calls[1].Arguments["timeout"], "30") - } -} - -func TestExtractXMLToolCalls_NoXML(t *testing.T) { - calls := extractXMLToolCalls("just regular text") - if len(calls) != 0 { - t.Errorf("expected 0 tool calls, got %d", len(calls)) - } -} - -func TestStripXMLToolCalls(t *testing.T) { - text := `Let me run that. -<vendor:toolcall> -<invoke name="exec"> -<parameter name="command">echo hello</parameter> -</invoke> -</vendor:toolcall> -Done.` - - got := stripXMLToolCalls(text) - if strings.Contains(got, "toolcall") { - t.Errorf("should remove XML block, got %q", got) - } - if !strings.Contains(got, "Let me run that.") { - t.Errorf("should keep text before, got %q", got) - } - if !strings.Contains(got, "Done.") { - t.Errorf("should keep text after, got %q", got) - } -} - -func TestExtractXMLToolCalls_MismatchedCloseTag(t *testing.T) { - // MiniMax uses <minimax:toolcall> but closes with </minimax:tool_call> (underscore) - text := `<minimax:toolcall> -<invoke name="readfile"> -<parameter name="path">/home/user/project/pyproject.toml</parameter> -</invoke> -</minimax:tool_call>` - - calls := extractXMLToolCalls(text) - if len(calls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(calls)) - } - if calls[0].Name != "readfile" { - t.Errorf("Name = %q, want %q", calls[0].Name, "readfile") - } - if calls[0].Arguments["path"] != "/home/user/project/pyproject.toml" { - t.Errorf("Arguments[path] = %v, want pyproject.toml path", calls[0].Arguments["path"]) - } -} - -func TestStripXMLToolCalls_MismatchedCloseTag(t *testing.T) { - text := `今テスト走らせるね。` + //nolint:gosmopolitan // CJK test data - ` -<minimax:toolcall> -<invoke name="exec"> -<parameter name="command">cd /home/user && pytest</parameter> -</invoke> -</minimax:tool_call>` - - got := stripXMLToolCalls(text) - if strings.Contains(got, "toolcall") || strings.Contains(got, "tool_call") { - t.Errorf("should remove XML block, got %q", got) - } - if !strings.Contains(got, "今テスト走らせるね。") { //nolint:gosmopolitan // CJK test data - t.Errorf("should keep text before, got %q", got) - } -} - -func TestExtractXMLToolCalls_UnderscoreOpenTag(t *testing.T) { - // Opening tag also uses underscore: <minimax:tool_call> - text := `<minimax:tool_call> -<invoke name="exec"> -<parameter name="command">ls -la</parameter> -</invoke> -</minimax:tool_call>` - - calls := extractXMLToolCalls(text) - if len(calls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(calls)) - } - if calls[0].Name != "exec" { - t.Errorf("Name = %q, want %q", calls[0].Name, "exec") - } - if calls[0].Arguments["command"] != "ls -la" { - t.Errorf("Arguments[command] = %v, want %q", calls[0].Arguments["command"], "ls -la") - } -} - -func TestExtractXMLToolCalls_HyphenTag(t *testing.T) { - // Hypothetical: <vendor:Tool-Call> - text := `<vendor:Tool-Call> -<invoke name="read_file"> -<parameter name="path">/etc/hosts</parameter> -</invoke> -</vendor:tool-call>` - - calls := extractXMLToolCalls(text) - if len(calls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(calls)) - } - if calls[0].Name != "read_file" { - t.Errorf("Name = %q, want %q", calls[0].Name, "read_file") - } -} - -func TestStripXMLToolCalls_UnderscoreOpenTag(t *testing.T) { - text := `Here is the result. -<minimax:tool_call> -<invoke name="exec"> -<parameter name="command">ls</parameter> -</invoke> -</minimax:toolcall> -Finished.` - - got := stripXMLToolCalls(text) - if strings.Contains(got, "tool_call") || strings.Contains(got, "toolcall") { - t.Errorf("should remove XML block, got %q", got) - } - if !strings.Contains(got, "Here is the result.") { - t.Errorf("should keep text before, got %q", got) - } - if !strings.Contains(got, "Finished.") { - t.Errorf("should keep text after, got %q", got) - } -} - -func TestExtractXMLToolCalls_OrphanedClosingTag(t *testing.T) { - // LLM emits [TOOLCALL] marker + <invoke> with orphaned closing tag (no opening tag) - text := "了解!確認するね。\n[TOOLCALL]\n<invoke name=\"listdir\">\n<parameter name=\"path\">/home/user/workspace</parameter>\n</invoke>\n</minimax:tool_call>" //nolint:gosmopolitan // CJK test data - - calls := extractXMLToolCalls(text) - if len(calls) != 1 { - t.Fatalf("expected 1 tool call, got %d", len(calls)) - } - if calls[0].Name != "listdir" { - t.Errorf("Name = %q, want %q", calls[0].Name, "listdir") - } - if calls[0].Arguments["path"] != "/home/user/workspace" { - t.Errorf("Arguments[path] = %v, want /home/user/workspace", calls[0].Arguments["path"]) - } -} - -func TestStripXMLToolCalls_OrphanedClosingTag(t *testing.T) { - text := "了解!確認するね。\n[TOOLCALL]\n<invoke name=\"listdir\">\n<parameter name=\"path\">/home/user</parameter>\n</invoke>\n</minimax:tool_call>" //nolint:gosmopolitan // CJK test data - got := stripXMLToolCalls(text) - if strings.Contains(got, "invoke") || strings.Contains(got, "TOOLCALL") || strings.Contains(got, "minimax") { - t.Errorf("should remove orphaned closing tag block, got %q", got) - } - if !strings.Contains(got, "了解") { //nolint:gosmopolitan // CJK test data - t.Errorf("should keep user-facing text, got %q", got) - } -} - -func TestStripXMLToolCalls_NoXML(t *testing.T) { - text := "Just regular text." - got := stripXMLToolCalls(text) - if got != text { - t.Errorf("stripXMLToolCalls() = %q, want %q", got, text) - } -} - -func TestNormalizeAlpha(t *testing.T) { - tests := []struct { - input, want string - }{ - {"toolcall", "toolcall"}, - {"tool_call", "toolcall"}, - {"Tool-Call", "toolcall"}, - {"ReadFile", "readfile"}, - {"read_file", "readfile"}, - {"EXEC", "exec"}, - {"web123search", "websearch"}, - {"", ""}, - } - for _, tt := range tests { - got := normalizeAlpha(tt.input) - if got != tt.want { - t.Errorf("normalizeAlpha(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - -func TestLevenshtein(t *testing.T) { - tests := []struct { - a, b string - want int - }{ - {"", "", 0}, - {"abc", "", 3}, - {"", "abc", 3}, - {"toolcall", "toolcall", 0}, - {"toolcall", "tool_call", 1}, - {"toolcall", "tool-call", 1}, - {"toolcall", "ToolCall", 2}, // T and C - {"kitten", "sitting", 3}, - } - for _, tt := range tests { - got := levenshtein(tt.a, tt.b) - if got != tt.want { - t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) - } - } -} - -func TestIsToolCallTag(t *testing.T) { - // Should match — toolcall variants - for _, name := range []string{"toolcall", "tool_call", "tool-call", "ToolCall", "Toolcall", "toolCall", "TOOLCALL"} { - if !isToolCallTag(name) { - t.Errorf("isToolCallTag(%q) = false, want true", name) - } - } - // Should match — function_call variants - for _, name := range []string{"function_call", "FunctionCall", "functioncall", "FUNCTION_CALL"} { - if !isToolCallTag(name) { - t.Errorf("isToolCallTag(%q) = false, want true", name) - } - } - // Should match — tool_use variants - for _, name := range []string{"tool_use", "ToolUse", "tooluse", "TOOL_USE"} { - if !isToolCallTag(name) { - t.Errorf("isToolCallTag(%q) = false, want true", name) - } - } - // Should NOT match - for _, name := range []string{"invoke", "parameter", "function", "result", "hello", "content"} { - if isToolCallTag(name) { - t.Errorf("isToolCallTag(%q) = true, want false", name) - } - } -} diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go index c9b7e9fa2..13f53ad9e 100644 --- a/pkg/providers/codex_cli_provider.go +++ b/pkg/providers/codex_cli_provider.go @@ -115,7 +115,7 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio } if len(tools) > 0 { - sb.WriteString(p.buildToolsPrompt(tools)) + sb.WriteString(buildCLIToolsPrompt(tools)) sb.WriteString("\n\n") } @@ -128,39 +128,6 @@ func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinitio return sb.String() } -// buildToolsPrompt creates a tool definitions section for the prompt. -func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - var sb strings.Builder - - sb.WriteString("## Available Tools\n\n") - sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") - sb.WriteString("```json\n") - sb.WriteString( - `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, - ) - sb.WriteString("\n```\n\n") - sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") - sb.WriteString("### Tool Definitions:\n\n") - - for _, tool := range tools { - if tool.Type != "function" { - continue - } - sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) - if tool.Function.Description != "" { - sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) - } - if len(tool.Function.Parameters) > 0 { - sb.WriteString("Parameters:\n```json\n") - sb.Write(tool.Function.Parameters) - sb.WriteString("\n```\n") - } - sb.WriteString("\n") - } - - return sb.String() -} - // codexEvent represents a single JSONL event from `codex exec --json`. type codexEvent struct { Type string `json:"type"` diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go index e537d7e31..d4dbaa9c5 100644 --- a/pkg/providers/codex_cli_provider_test.go +++ b/pkg/providers/codex_cli_provider_test.go @@ -77,7 +77,7 @@ func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) { t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1") } if resp.ToolCalls[0].Function.Arguments["path"] != "/tmp/test.txt" { - t.Errorf("ToolCalls[0].Function.Arguments[path] = %v", resp.ToolCalls[0].Function.Arguments["path"]) + t.Errorf("ToolCalls[0].Function.Arguments = %v", resp.ToolCalls[0].Function.Arguments) } // Content should have the tool call JSON stripped if strings.Contains(resp.Content, "tool_calls") { @@ -292,12 +292,7 @@ func TestBuildPrompt_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get current weather", - Parameters: MustMarshalParameters(map[string]any{ - "type": "object", - "properties": map[string]any{ - "city": map[string]any{"type": "string"}, - }, - }), + Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`), }, }, } @@ -490,7 +485,7 @@ echo '{"type":"turn.completed"}'` } messages := []Message{{Role: "user", Content: "test"}} - _, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil) + _, err := p.Chat(context.Background(), messages, nil, "gpt-5.3-codex", nil) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -502,7 +497,7 @@ echo '{"type":"turn.completed"}'` } args := string(argsData) - if !strings.Contains(args, "-m gpt-5.2-codex") { + if !strings.Contains(args, "-m gpt-5.3-codex") { t.Errorf("args should contain model flag, got: %s", args) } if !strings.Contains(args, "-C /tmp/test-workspace") { diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index c8df94b8d..1270813d6 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -16,7 +16,7 @@ import ( ) const ( - codexDefaultModel = "gpt-5.2" + codexDefaultModel = "gpt-5.3-codex" codexDefaultInstructions = "You are Codex, a coding assistant." ) @@ -317,19 +317,23 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) return "", "", false } - args := tc.Arguments - if len(args) == 0 && tc.Function != nil { - args = tc.Function.Arguments - } - if len(args) == 0 { - return name, "{}", true + if len(tc.Arguments) > 0 { + argsJSON, err := json.Marshal(tc.Arguments) + if err != nil { + return "", "", false + } + return name, string(argsJSON), true } - argsJSON, err := json.Marshal(args) - if err != nil { - return "", "", false + if tc.Function != nil && len(tc.Function.Arguments) > 0 { + argsJSON, err := json.Marshal(tc.Function.Arguments) + if err != nil { + return "", "", false + } + return name, string(argsJSON), true } - return name, string(argsJSON), true + + return name, "{}", true } func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { @@ -345,13 +349,9 @@ func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []resp if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { continue } - params := t.Function.ParametersMap() - if params == nil { - params = map[string]any{} - } ft := responses.FunctionToolParam{ Name: t.Function.Name, - Parameters: params, + Parameters: t.Function.ParametersMap(), Strict: openai.Opt(false), } if t.Function.Description != "" { @@ -386,10 +386,6 @@ func parseCodexResponse(resp *responses.Response) *LLMResponse { ID: item.CallID, Name: item.Name, Arguments: args, - Function: &FunctionCall{ - Name: item.Name, - Arguments: cloneToolArgs(args), - }, }) } } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index c0db1c381..33d5c8322 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -114,12 +114,7 @@ func TestBuildCodexParams_WithTools(t *testing.T) { Function: ToolFunctionDefinition{ Name: "get_weather", Description: "Get weather", - Parameters: MustMarshalParameters(map[string]any{ - "type": "object", - "properties": map[string]any{ - "city": map[string]any{"type": "string"}, - }, - }), + Parameters: json.RawMessage(`{"type":"object","properties":{"city":{"type":"string"}}}`), }, }, } @@ -166,9 +161,7 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { Function: ToolFunctionDefinition{ Name: "web_search", Description: "local web search", - Parameters: MustMarshalParameters(map[string]any{ - "type": "object", - }), + Parameters: json.RawMessage(`{"type":"object"}`), }, }, { @@ -176,9 +169,7 @@ func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { Function: ToolFunctionDefinition{ Name: "read_file", Description: "read file", - Parameters: MustMarshalParameters(map[string]any{ - "type": "object", - }), + Parameters: json.RawMessage(`{"type":"object"}`), }, }, } @@ -568,7 +559,7 @@ func TestCodexProvider_ChatRoundTrip_ModelFallbackFromUnsupported(t *testing.T) provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil) + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.3-codex", nil) if err != nil { t.Fatalf("Chat() error: %v", err) } @@ -599,7 +590,7 @@ func TestResolveCodexModel(t *testing.T) { wantFallback: true, }, {name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true}, - {name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false}, + {name: "openai prefix", input: "openai/gpt-5.3-codex", wantModel: "gpt-5.3-codex", wantFallback: false}, {name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false}, } diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index aaceb891a..d2afe2943 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -36,13 +36,14 @@ type providerSelection struct { } func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { - return resolveProviderSelectionByName(cfg, strings.ToLower(cfg.Agents.Defaults.Provider)) -} - -func resolveProviderSelectionByName(cfg *config.Config, providerName string) (providerSelection, error) { model := cfg.Agents.Defaults.GetModelName() + providerName := strings.ToLower(cfg.Agents.Defaults.Provider) lowerModel := strings.ToLower(model) + if providerName == "" && model == "" { + return providerSelection{}, fmt.Errorf("no model configured: agents.defaults.model is empty") + } + sel := providerSelection{ providerType: providerTypeHTTPCompat, model: model, @@ -211,6 +212,24 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr sel.apiBase = "https://api.mistral.ai/v1" } } + case "minimax": + if cfg.Providers.Minimax.APIKey != "" { + sel.apiKey = cfg.Providers.Minimax.APIKey + sel.apiBase = cfg.Providers.Minimax.APIBase + sel.proxy = cfg.Providers.Minimax.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.minimaxi.com/v1" + } + } + case "longcat": + if cfg.Providers.LongCat.APIKey != "" { + sel.apiKey = cfg.Providers.LongCat.APIKey + sel.apiBase = cfg.Providers.LongCat.APIBase + sel.proxy = cfg.Providers.LongCat.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.longcat.chat/openai" + } + } case "github_copilot", "copilot": sel.providerType = providerTypeGitHubCopilot if cfg.Providers.GitHubCopilot.APIBase != "" { @@ -328,6 +347,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr if sel.apiBase == "" { sel.apiBase = "https://api.mistral.ai/v1" } + case (strings.Contains(lowerModel, "minimax") || strings.HasPrefix(model, "minimax/")) && cfg.Providers.Minimax.APIKey != "": + sel.apiKey = cfg.Providers.Minimax.APIKey + sel.apiBase = cfg.Providers.Minimax.APIBase + sel.proxy = cfg.Providers.Minimax.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.minimaxi.com/v1" + } case strings.HasPrefix(model, "avian/") && cfg.Providers.Avian.APIKey != "": sel.apiKey = cfg.Providers.Avian.APIKey sel.apiBase = cfg.Providers.Avian.APIBase @@ -335,6 +361,13 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr if sel.apiBase == "" { sel.apiBase = "https://api.avian.io/v1" } + case (strings.Contains(lowerModel, "longcat") || strings.HasPrefix(model, "longcat/")) && cfg.Providers.LongCat.APIKey != "": + sel.apiKey = cfg.Providers.LongCat.APIKey + sel.apiBase = cfg.Providers.LongCat.APIBase + sel.proxy = cfg.Providers.LongCat.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.longcat.chat/openai" + } case cfg.Providers.VLLM.APIBase != "": sel.apiKey = cfg.Providers.VLLM.APIKey sel.apiBase = cfg.Providers.VLLM.APIBase @@ -365,31 +398,3 @@ func resolveProviderSelectionByName(cfg *config.Config, providerName string) (pr return sel, nil } - -// CreateProviderByName creates a provider for the given explicit provider name. -// Used by the fallback chain to resolve cross-provider candidates. -func CreateProviderByName(cfg *config.Config, providerName string) (LLMProvider, error) { - sel, err := resolveProviderSelectionByName(cfg, strings.ToLower(providerName)) - if err != nil { - return nil, err - } - - switch sel.providerType { - case providerTypeClaudeAuth: - return createClaudeAuthProvider() - case providerTypeCodexAuth: - return createCodexAuthProvider() - case providerTypeCodexCLIToken: - c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()) - c.enableWebSearch = sel.enableWebSearch - return c, nil - case providerTypeClaudeCLI: - return NewClaudeCliProvider(sel.workspace), nil - case providerTypeCodexCLI: - return NewCodexCliProvider(sel.workspace), nil - case providerTypeGitHubCopilot: - return NewGitHubCopilotProvider(sel.apiBase, sel.connectMode, sel.model) - default: - return NewHTTPProvider(sel.apiKey, sel.apiBase, sel.proxy), nil - } -} diff --git a/pkg/providers/factory_ext_test.go b/pkg/providers/factory_ext_test.go new file mode 100644 index 000000000..1ea0e5d11 --- /dev/null +++ b/pkg/providers/factory_ext_test.go @@ -0,0 +1,74 @@ +package providers + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestCreateProviderByName_OpenAI_OAuth(t *testing.T) { + originalGetCredential := getCredential + t.Cleanup(func() { getCredential = originalGetCredential }) + + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "openai" { + t.Fatalf("provider = %q, want openai", provider) + } + return &auth.AuthCredential{ + AccessToken: "openai-token", + AccountID: "acct_test", + }, nil + } + + cfg := config.DefaultConfig() + cfg.Providers.OpenAI.AuthMethod = "oauth" + + provider, err := CreateProviderByName(cfg, "openai") + if err != nil { + t.Fatalf("CreateProviderByName() error = %v", err) + } + + if _, ok := provider.(*CodexProvider); !ok { + t.Fatalf("provider type = %T, want *CodexProvider", provider) + } +} + +func TestCreateProviderByName_VLLM(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Providers.VLLM.APIKey = "test-vllm-key" + cfg.Providers.VLLM.APIBase = "https://api.example.com/v1" + + provider, err := CreateProviderByName(cfg, "vllm") + if err != nil { + t.Fatalf("CreateProviderByName() error = %v", err) + } + + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("provider type = %T, want *HTTPProvider", provider) + } +} + +func TestCreateProviderByName_Unknown(t *testing.T) { + cfg := config.DefaultConfig() + + _, err := CreateProviderByName(cfg, "nonexistent-provider") + if err == nil { + t.Fatal("expected error for unknown provider, got nil") + } +} + +func TestCreateProviderByName_CaseInsensitive(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Providers.VLLM.APIKey = "test-key" + cfg.Providers.VLLM.APIBase = "https://example.com/v1" + + provider, err := CreateProviderByName(cfg, "VLLM") + if err != nil { + t.Fatalf("CreateProviderByName() error = %v", err) + } + + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("provider type = %T, want *HTTPProvider", provider) + } +} diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index da87ba94f..217d8f1de 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -8,10 +8,8 @@ package providers import ( "fmt" "strings" - "time" "github.com/sipeed/picoclaw/pkg/config" - "github.com/sipeed/picoclaw/pkg/providers/openai_compat" ) // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. @@ -86,33 +84,18 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, - openai_compat.WithMaxTokensField(cfg.MaxTokensField), - openai_compat.WithStream(boolDefault(cfg.Stream, false)), - openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), - openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), - ), modelID, nil - - case "minimax": - // MiniMax uses a non-standard endpoint path and defaults to SSE streaming. - if cfg.APIKey == "" && cfg.APIBase == "" { - return nil, "", fmt.Errorf("api_key or api_base is required for minimax protocol") - } - apiBase := cfg.APIBase - if apiBase == "" { - apiBase = getDefaultAPIBase(protocol) - } - return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, - openai_compat.WithEndpointPath("/text/chatcompletion_v2"), - openai_compat.WithMaxTokensField(cfg.MaxTokensField), - openai_compat.WithStream(boolDefault(cfg.Stream, true)), - openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), - openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, ), modelID, nil case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", - "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian": + "vivgrid", "volcengine", "vllm", "qwen", "mistral", "avian", + "minimax", "longcat": // All other OpenAI-compatible HTTP providers if cfg.APIKey == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) @@ -121,11 +104,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } - return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, - openai_compat.WithMaxTokensField(cfg.MaxTokensField), - openai_compat.WithStream(boolDefault(cfg.Stream, false)), - openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), - openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, ), modelID, nil case "anthropic": @@ -145,10 +129,12 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err if cfg.APIKey == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } - return NewHTTPProviderWithOptions(cfg.APIKey, apiBase, cfg.Proxy, - openai_compat.WithMaxTokensField(cfg.MaxTokensField), - openai_compat.WithRequestTimeout(time.Duration(cfg.RequestTimeout)*time.Second), - openai_compat.WithMinInterval(rpmToMinInterval(cfg.RPM)), + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cfg.APIKey, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, ), modelID, nil case "antigravity": @@ -188,6 +174,93 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } } +// CreateProviderByName creates a provider from the legacy ProvidersConfig by +// provider name (case-insensitive). It builds a ModelConfig from the named +// provider and delegates to CreateProviderFromConfig. +func CreateProviderByName(cfg *config.Config, name string) (LLMProvider, error) { + name = strings.ToLower(name) + p := cfg.Providers + + var pc config.ProviderConfig + protocol := name + + switch name { + case "openai", "gpt": + pc = config.ProviderConfig{ + APIKey: p.OpenAI.APIKey, + APIBase: p.OpenAI.APIBase, + Proxy: p.OpenAI.Proxy, + RequestTimeout: p.OpenAI.RequestTimeout, + AuthMethod: p.OpenAI.AuthMethod, + } + protocol = "openai" + case "anthropic", "claude": + pc = p.Anthropic + protocol = "anthropic" + case "litellm": + pc = p.LiteLLM + case "openrouter": + pc = p.OpenRouter + case "groq": + pc = p.Groq + case "zhipu": + pc = p.Zhipu + case "vllm": + pc = p.VLLM + case "gemini": + pc = p.Gemini + case "nvidia": + pc = p.Nvidia + case "ollama": + pc = p.Ollama + case "moonshot": + pc = p.Moonshot + case "shengsuanyun": + pc = p.ShengSuanYun + case "deepseek": + pc = p.DeepSeek + case "cerebras": + pc = p.Cerebras + case "vivgrid": + pc = p.Vivgrid + case "volcengine": + pc = p.VolcEngine + case "github-copilot", "copilot": + pc = p.GitHubCopilot + protocol = "github-copilot" + case "antigravity": + pc = p.Antigravity + case "qwen": + pc = p.Qwen + case "mistral": + pc = p.Mistral + case "avian": + pc = p.Avian + case "minimax": + pc = p.Minimax + case "longcat": + pc = p.LongCat + default: + return nil, fmt.Errorf("unknown provider %q", name) + } + + mc := &config.ModelConfig{ + ModelName: name, + Model: protocol + "/default", + APIKey: pc.APIKey, + APIBase: pc.APIBase, + Proxy: pc.Proxy, + RequestTimeout: pc.RequestTimeout, + AuthMethod: pc.AuthMethod, + } + + provider, _, err := CreateProviderFromConfig(mc) + if err != nil { + return nil, err + } + return provider, nil +} + // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { switch protocol { @@ -223,30 +296,15 @@ func getDefaultAPIBase(protocol string) string { return "https://dashscope.aliyuncs.com/compatible-mode/v1" case "vllm": return "http://localhost:8000/v1" - case "minimax": - return "https://api.minimax.io/v1" case "mistral": return "https://api.mistral.ai/v1" case "avian": return "https://api.avian.io/v1" + case "minimax": + return "https://api.minimaxi.com/v1" + case "longcat": + return "https://api.longcat.chat/openai" default: return "" } } - -// rpmToMinInterval converts a requests-per-minute limit to a minimum interval -// between consecutive requests. Returns 0 (no throttle) when rpm <= 0. -func rpmToMinInterval(rpm int) time.Duration { - if rpm <= 0 { - return 0 - } - return time.Minute / time.Duration(rpm) -} - -// boolDefault dereferences a *bool, returning def when nil. -func boolDefault(p *bool, def bool) bool { - if p != nil { - return *p - } - return def -} diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 17bc55d25..6c7bb4795 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -113,6 +113,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { {"vllm", "vllm"}, {"deepseek", "deepseek"}, {"ollama", "ollama"}, + {"longcat", "longcat"}, } for _, tt := range tests { @@ -162,6 +163,29 @@ func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { } } +func TestCreateProviderFromConfig_LongCat(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-longcat", + Model: "longcat/LongCat-Flash-Thinking", + APIKey: "test-key", + APIBase: "https://api.longcat.chat/openai", + } + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "LongCat-Flash-Thinking" { + t.Errorf("modelID = %q, want %q", modelID, "LongCat-Flash-Thinking") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + func TestCreateProviderFromConfig_Anthropic(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-anthropic", diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go index 64cbc211b..91469f25b 100644 --- a/pkg/providers/factory_test.go +++ b/pkg/providers/factory_test.go @@ -178,6 +178,26 @@ func TestResolveProviderSelection(t *testing.T) { wantAPIBase: "https://api.moonshot.cn/v1", wantProxy: "http://127.0.0.1:7890", }, + { + name: "explicit longcat provider uses defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "longcat" + cfg.Providers.LongCat.APIKey = "longcat-key" + cfg.Providers.LongCat.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://api.longcat.chat/openai", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "longcat model fallback uses longcat base default", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "longcat/LongCat-Flash-Thinking" + cfg.Providers.LongCat.APIKey = "longcat-key" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://api.longcat.chat/openai", + }, { name: "missing keys returns model config error", setup: func(cfg *config.Config) { @@ -329,69 +349,3 @@ func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) { // which is not yet implemented in the new factory_provider.go t.Skip("OpenAI OAuth via model_list not yet implemented") } - -func TestCreateProviderByName_OpenAI_OAuth(t *testing.T) { - originalGetCredential := getCredential - t.Cleanup(func() { getCredential = originalGetCredential }) - - getCredential = func(provider string) (*auth.AuthCredential, error) { - if provider != "openai" { - t.Fatalf("provider = %q, want openai", provider) - } - return &auth.AuthCredential{ - AccessToken: "openai-token", - AccountID: "acct_test", - }, nil - } - - cfg := config.DefaultConfig() - cfg.Providers.OpenAI.AuthMethod = "oauth" - - provider, err := CreateProviderByName(cfg, "openai") - if err != nil { - t.Fatalf("CreateProviderByName() error = %v", err) - } - - if _, ok := provider.(*CodexProvider); !ok { - t.Fatalf("provider type = %T, want *CodexProvider", provider) - } -} - -func TestCreateProviderByName_VLLM(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Providers.VLLM.APIKey = "test-vllm-key" - cfg.Providers.VLLM.APIBase = "https://api.example.com/v1" - - provider, err := CreateProviderByName(cfg, "vllm") - if err != nil { - t.Fatalf("CreateProviderByName() error = %v", err) - } - - if _, ok := provider.(*HTTPProvider); !ok { - t.Fatalf("provider type = %T, want *HTTPProvider", provider) - } -} - -func TestCreateProviderByName_Unknown(t *testing.T) { - cfg := config.DefaultConfig() - - _, err := CreateProviderByName(cfg, "nonexistent-provider") - if err == nil { - t.Fatal("expected error for unknown provider, got nil") - } -} - -func TestCreateProviderByName_CaseInsensitive(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Providers.VLLM.APIKey = "test-key" - cfg.Providers.VLLM.APIBase = "https://example.com/v1" - - provider, err := CreateProviderByName(cfg, "VLLM") - if err != nil { - t.Fatalf("CreateProviderByName() error = %v", err) - } - - if _, ok := provider.(*HTTPProvider); !ok { - t.Fatalf("provider type = %T, want *HTTPProvider", provider) - } -} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 49f45938b..5c328f418 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -42,12 +42,6 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( } } -func NewHTTPProviderWithOptions(apiKey, apiBase, proxy string, opts ...openai_compat.Option) *HTTPProvider { - return &HTTPProvider{ - delegate: openai_compat.NewProvider(apiKey, apiBase, proxy, opts...), - } -} - func (p *HTTPProvider) Chat( ctx context.Context, messages []Message, @@ -55,38 +49,9 @@ func (p *HTTPProvider) Chat( model string, options map[string]any, ) (*LLMResponse, error) { - resp, err := p.delegate.Chat(ctx, messages, tools, model, options) - if err != nil { - return nil, err - } - // If provider returned no structured tool_calls but Content has XML - // tool call blocks (e.g. <ns:toolcall>), parse them as a fallback. - if len(resp.ToolCalls) == 0 { - if xmlCalls := extractXMLToolCalls(resp.Content); len(xmlCalls) > 0 { - resp.ToolCalls = xmlCalls - } - } - // Strip XML tool call artifacts from Content regardless. - resp.Content = stripXMLToolCalls(resp.Content) - return resp, nil + return p.delegate.Chat(ctx, messages, tools, model, options) } func (p *HTTPProvider) GetDefaultModel() string { return "" } - -// CanStream returns true when the underlying provider uses SSE streaming. -func (p *HTTPProvider) CanStream() bool { - return p.delegate.CanStream() -} - -// ChatStream opens an SSE stream and returns a channel of StreamEvent. -func (p *HTTPProvider) ChatStream( - ctx context.Context, - messages []Message, - tools []ToolDefinition, - model string, - options map[string]any, -) (<-chan StreamEvent, error) { - return p.delegate.ChatStream(ctx, messages, tools, model, options) -} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 8759c7ea5..315c2139e 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -162,7 +162,7 @@ func (p *Provider) buildHTTPRequest( requestBody := map[string]any{ "model": model, - "messages": stripSystemParts(messages), + "messages": serializeMessages(messages), } if len(tools) > 0 { @@ -205,9 +205,10 @@ func (p *Provider) buildHTTPRequest( // The key is typically the agent ID -- stable per agent, shared across requests. // See: https://platform.openai.com/docs/guides/prompt-caching // Prompt caching is only supported by OpenAI-native endpoints. - // Gemini and other providers reject unknown fields, so skip for non-OpenAI APIs. + // Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown + // fields with 422 errors, so only include it for OpenAI APIs. if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" { - if !strings.Contains(p.apiBase, "generativelanguage.googleapis.com") { + if supportsPromptCacheKey(p.apiBase) { requestBody["prompt_cache_key"] = cacheKey } } @@ -279,17 +280,40 @@ func (p *Provider) Chat( } defer resp.Body.Close() + contentType := resp.Header.Get("Content-Type") + + // Non-200: read a prefix to tell HTML error page apart from JSON error body. if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body)) + body, readErr := io.ReadAll(io.LimitReader(resp.Body, 256)) + if readErr != nil { + return nil, fmt.Errorf("failed to read response: %w", readErr) + } + if looksLikeHTML(body, contentType) { + return nil, wrapHTMLResponseError(resp.StatusCode, body, contentType, p.apiBase) + } + return nil, fmt.Errorf( + "API request failed:\n Status: %d\n Body: %s", + resp.StatusCode, + responsePreview(body, 128), + ) } - body, err := io.ReadAll(resp.Body) + // Peek without consuming so the full stream reaches the JSON decoder. + reader := bufio.NewReader(resp.Body) + prefix, err := reader.Peek(256) // io.EOF/ErrBufferFull are normal; only real errors abort + if err != nil && err != io.EOF && err != bufio.ErrBufferFull { + return nil, fmt.Errorf("failed to inspect response: %w", err) + } + if looksLikeHTML(prefix, contentType) { + return nil, wrapHTMLResponseError(resp.StatusCode, prefix, contentType, p.apiBase) + } + + out, err := parseResponse(reader) if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + return nil, fmt.Errorf("failed to parse JSON response: %w", err) } - return parseResponse(body) + return out, nil } // CanStream returns true when this provider is configured for SSE streaming. @@ -472,7 +496,58 @@ func AccumulateStream(ch <-chan protocoltypes.StreamEvent) (*LLMResponse, error) return result, nil } -func parseResponse(body []byte) (*LLMResponse, error) { +func wrapHTMLResponseError(statusCode int, body []byte, contentType, apiBase string) error { + respPreview := responsePreview(body, 128) + return fmt.Errorf( + "API request failed: %s returned HTML instead of JSON (content-type: %s); check api_base or proxy configuration.\n Status: %d\n Body: %s", + apiBase, + contentType, + statusCode, + respPreview, + ) +} + +func looksLikeHTML(body []byte, contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + if strings.Contains(contentType, "text/html") || strings.Contains(contentType, "application/xhtml+xml") { + return true + } + prefix := bytes.ToLower(leadingTrimmedPrefix(body, 128)) + return bytes.HasPrefix(prefix, []byte("<!doctype html")) || + bytes.HasPrefix(prefix, []byte("<html")) || + bytes.HasPrefix(prefix, []byte("<head")) || + bytes.HasPrefix(prefix, []byte("<body")) +} + +func leadingTrimmedPrefix(body []byte, maxLen int) []byte { + i := 0 + for i < len(body) { + switch body[i] { + case ' ', '\t', '\n', '\r', '\f', '\v': + i++ + default: + end := i + maxLen + if end > len(body) { + end = len(body) + } + return body[i:end] + } + } + return nil +} + +func responsePreview(body []byte, maxLen int) string { + trimmed := bytes.TrimSpace(body) + if len(trimmed) == 0 { + return "<empty>" + } + if len(trimmed) <= maxLen { + return string(trimmed) + } + return string(trimmed[:maxLen]) + "..." +} + +func parseResponse(body io.Reader) (*LLMResponse, error) { var apiResponse struct { Choices []struct { Message struct { @@ -484,8 +559,8 @@ func parseResponse(body []byte) (*LLMResponse, error) { ID string `json:"id"` Type string `json:"type"` Function *struct { - Name string `json:"name"` - Arguments string `json:"arguments"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` } `json:"function"` ExtraContent *struct { Google *struct { @@ -499,8 +574,8 @@ func parseResponse(body []byte) (*LLMResponse, error) { Usage *UsageInfo `json:"usage"` } - if err := json.Unmarshal(body, &apiResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) + if err := json.NewDecoder(body).Decode(&apiResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) } if len(apiResponse.Choices) == 0 { @@ -524,12 +599,7 @@ func parseResponse(body []byte) (*LLMResponse, error) { if tc.Function != nil { name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) - arguments["raw"] = tc.Function.Arguments - } - } + arguments = decodeToolCallArguments(tc.Function.Arguments, name) } // Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence @@ -567,93 +637,105 @@ func parseResponse(body []byte) (*LLMResponse, error) { }, nil } +func decodeToolCallArguments(raw json.RawMessage, name string) map[string]any { + arguments := make(map[string]any) + raw = bytes.TrimSpace(raw) + if len(raw) == 0 || bytes.Equal(raw, []byte("null")) { + return arguments + } + + var decoded any + if err := json.Unmarshal(raw, &decoded); err != nil { + log.Printf("openai_compat: failed to decode tool call arguments payload for %q: %v", name, err) + arguments["raw"] = string(raw) + return arguments + } + + switch v := decoded.(type) { + case string: + if strings.TrimSpace(v) == "" { + return arguments + } + if err := json.Unmarshal([]byte(v), &arguments); err != nil { + log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) + arguments["raw"] = v + } + return arguments + case map[string]any: + return v + default: + log.Printf("openai_compat: unsupported tool call arguments type for %q: %T", name, decoded) + arguments["raw"] = string(raw) + return arguments + } +} + // openaiMessage is the wire-format message for OpenAI-compatible APIs. // It mirrors protocoltypes.Message but omits SystemParts, which is an // internal field that would be unknown to third-party endpoints. type openaiMessage struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []openaiToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` + Role string `json:"role"` + Content string `json:"content"` + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` } -type openaiToolCall struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Function *openaiFunctionCall `json:"function,omitempty"` -} - -type openaiFunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -// stripSystemParts converts []Message to []openaiMessage, dropping the -// SystemParts field so it doesn't leak into the JSON payload sent to -// OpenAI-compatible APIs (some strict endpoints reject unknown fields). -func stripSystemParts(messages []Message) []openaiMessage { - out := make([]openaiMessage, len(messages)) - for i, m := range messages { - out[i] = openaiMessage{ - Role: m.Role, - Content: m.Content, - ToolCalls: toOpenAIWireToolCalls(m.ToolCalls), - ToolCallID: m.ToolCallID, - } - } - return out -} - -func toOpenAIWireToolCalls(toolCalls []ToolCall) []openaiToolCall { - if len(toolCalls) == 0 { - return nil - } - - out := make([]openaiToolCall, 0, len(toolCalls)) - for _, tc := range toolCalls { - name, args := normalizeOpenAIWireToolCall(tc) - if name == "" { +// serializeMessages converts internal Message structs to the OpenAI wire format. +// - Strips SystemParts (unknown to third-party endpoints) +// - Converts messages with Media to multipart content format (text + image_url parts) +// - Preserves ToolCallID, ToolCalls, and ReasoningContent for all messages +func serializeMessages(messages []Message) []any { + out := make([]any, 0, len(messages)) + for _, m := range messages { + if len(m.Media) == 0 { + out = append(out, openaiMessage{ + Role: m.Role, + Content: m.Content, + ReasoningContent: m.ReasoningContent, + ToolCalls: m.ToolCalls, + ToolCallID: m.ToolCallID, + }) continue } - argsJSON, err := json.Marshal(args) - if err != nil { - argsJSON = []byte(`{}`) + // Multipart content format for messages with media + parts := make([]map[string]any, 0, 1+len(m.Media)) + if m.Content != "" { + parts = append(parts, map[string]any{ + "type": "text", + "text": m.Content, + }) + } + for _, mediaURL := range m.Media { + if strings.HasPrefix(mediaURL, "data:image/") { + parts = append(parts, map[string]any{ + "type": "image_url", + "image_url": map[string]any{ + "url": mediaURL, + }, + }) + } } - wire := openaiToolCall{ - ID: tc.ID, - Type: tc.Type, - Function: &openaiFunctionCall{ - Name: name, - Arguments: string(argsJSON), - }, + msg := map[string]any{ + "role": m.Role, + "content": parts, } - out = append(out, wire) - } - - if len(out) == 0 { - return nil + if m.ToolCallID != "" { + msg["tool_call_id"] = m.ToolCallID + } + if len(m.ToolCalls) > 0 { + msg["tool_calls"] = m.ToolCalls + } + if m.ReasoningContent != "" { + msg["reasoning_content"] = m.ReasoningContent + } + out = append(out, msg) } return out } -func normalizeOpenAIWireToolCall(tc ToolCall) (name string, args map[string]any) { - name = tc.Name - if name == "" && tc.Function != nil { - name = tc.Function.Name - } - - args = tc.Arguments - if len(args) == 0 && tc.Function != nil { - args = tc.Function.Arguments - } - if args == nil { - args = map[string]any{} - } - return name, args -} - func cloneOpenAIToolArgs(src map[string]any) map[string]any { if len(src) == 0 { return map[string]any{} @@ -677,17 +759,8 @@ func normalizeModel(model, apiBase string) string { prefix := strings.ToLower(before) switch prefix { - case "openai", - "moonshot", - "nvidia", - "groq", - "ollama", - "deepseek", - "google", - "openrouter", - "zhipu", - "minimax", - "mistral": + case "openai", "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", + "google", "openrouter", "zhipu", "minimax", "mistral", "vivgrid": return after default: return model @@ -759,3 +832,16 @@ type streamToolCallAcc struct { Name string Arguments strings.Builder } + +// supportsPromptCacheKey reports whether the given API base is known to +// support the prompt_cache_key request field. Currently only OpenAI's own +// API and Azure OpenAI support this. All other OpenAI-compatible providers +// (Mistral, Gemini, DeepSeek, Groq, etc.) reject unknown fields with 422 errors. +func supportsPromptCacheKey(apiBase string) bool { + u, err := url.Parse(apiBase) + if err != nil { + return false + } + host := u.Hostname() + return host == "api.openai.com" || strings.HasSuffix(host, ".openai.azure.com") +} diff --git a/pkg/providers/openai_compat/provider_ext_test.go b/pkg/providers/openai_compat/provider_ext_test.go new file mode 100644 index 000000000..e5f7af10f --- /dev/null +++ b/pkg/providers/openai_compat/provider_ext_test.go @@ -0,0 +1,456 @@ +package openai_compat + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) + +func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { + tests := []struct { + name string + input string + wantModel string + }{ + { + name: "strips groq prefix and keeps nested model", + input: "groq/openai/gpt-oss-120b", + wantModel: "openai/gpt-oss-120b", + }, + { + name: "strips ollama prefix", + input: "ollama/qwen2.5:14b", + wantModel: "qwen2.5:14b", + }, + { + name: "strips deepseek prefix", + input: "deepseek/deepseek-chat", + wantModel: "deepseek-chat", + }, + } + + for _, tt := range tests { //nolint:dupl + t.Run(tt.name, func(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["model"] != tt.wantModel { + t.Fatalf("model = %v, want %s", requestBody["model"], tt.wantModel) + } + }) + } +} + +func TestNormalizeModel_OpenAIPrefix(t *testing.T) { + if got := normalizeModel("openai/gpt-5.2", "https://api.openai.com/v1"); got != "gpt-5.2" { + t.Fatalf("normalizeModel(openai/gpt-5.2) = %q, want %q", got, "gpt-5.2") + } +} + +func TestProviderChat_StreamingTextResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/text/chatcompletion_v2" { + http.Error(w, "not found", http.StatusNotFound) + return + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if body["stream"] != true { + t.Error("expected stream=true in request body") + } + + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + chunks := []string{ + `data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":""}]}`, + `data: {"choices":[{"delta":{"content":" world"},"finish_reason":""}]}`, + `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`, + `data: [DONE]`, + } + for _, c := range chunks { + fmt.Fprintln(w, c) + fmt.Fprintln(w) + if flusher != nil { + flusher.Flush() + } + } + })) + defer server.Close() + + p := NewProvider("key", server.URL, "", + WithEndpointPath("/text/chatcompletion_v2"), + WithStream(true), + ) + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "MiniMax-M1", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if out.Content != "Hello world" { + t.Fatalf("Content = %q, want %q", out.Content, "Hello world") + } + if out.FinishReason != "stop" { + t.Fatalf("FinishReason = %q, want %q", out.FinishReason, "stop") + } + if out.Usage == nil || out.Usage.TotalTokens != 7 { + t.Fatalf("Usage.TotalTokens = %v, want 7", out.Usage) + } +} + +func TestProviderChat_StreamingToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + chunks := []string{ + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":""}]}`, + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":""}]}`, + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"SF\"}"}}]},"finish_reason":""}]}`, + `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}`, + `data: [DONE]`, + } + for _, c := range chunks { + fmt.Fprintln(w, c) + fmt.Fprintln(w) + if flusher != nil { + flusher.Flush() + } + } + })) + defer server.Close() + + p := NewProvider("key", server.URL, "", WithStream(true)) + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "test", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + tc := out.ToolCalls[0] + if tc.ID != "call_1" { + t.Fatalf("ToolCalls[0].ID = %q, want %q", tc.ID, "call_1") + } + if tc.Name != "get_weather" { + t.Fatalf("ToolCalls[0].Name = %q, want %q", tc.Name, "get_weather") + } + if tc.Arguments["city"] != "SF" { + t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", tc.Arguments["city"]) + } +} + +func TestProviderChat_CustomEndpointPath(t *testing.T) { + var hitPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitPath = r.URL.Path + resp := map[string]any{ + "choices": []map[string]any{ + {"message": map[string]any{"content": "ok"}, "finish_reason": "stop"}, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "", + WithEndpointPath("/text/chatcompletion_v2"), + ) + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if hitPath != "/text/chatcompletion_v2" { + t.Fatalf("endpoint path = %q, want %q", hitPath, "/text/chatcompletion_v2") + } +} + +func TestReadSSEIntoChannel_TextAndToolCalls(t *testing.T) { + sseData := strings.Join([]string{ + `data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":""}]}`, + ``, + `data: {"choices":[{"delta":{"content":" world"},"finish_reason":""}]}`, + ``, + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"greet","arguments":"{\"n"}}]},"finish_reason":""}]}`, + ``, + `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ame\":\"Bob\"}"}}]},"finish_reason":""}]}`, + ``, + `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}`, + ``, + `data: [DONE]`, + ``, + }, "\n") + + ch := make(chan protocoltypes.StreamEvent, 32) + go func() { + defer close(ch) + readSSEIntoChannel(context.Background(), strings.NewReader(sseData), ch) + }() + + var events []protocoltypes.StreamEvent + for ev := range ch { + events = append(events, ev) + } + + if len(events) < 3 { + t.Fatalf("got %d events, want at least 3", len(events)) + } + + if events[0].ContentDelta != "Hello" { + t.Errorf("events[0].ContentDelta = %q, want %q", events[0].ContentDelta, "Hello") + } + if events[1].ContentDelta != " world" { + t.Errorf("events[1].ContentDelta = %q, want %q", events[1].ContentDelta, " world") + } + + if len(events[2].ToolCallDeltas) != 1 || events[2].ToolCallDeltas[0].ID != "call_1" { + t.Errorf("events[2] should contain tool call with ID=call_1") + } + if events[2].ToolCallDeltas[0].Name != "greet" { + t.Errorf("events[2].ToolCallDeltas[0].Name = %q, want %q", events[2].ToolCallDeltas[0].Name, "greet") + } + + lastEv := events[len(events)-1] + if lastEv.FinishReason != "stop" { + t.Errorf("last event FinishReason = %q, want %q", lastEv.FinishReason, "stop") + } + if lastEv.Usage == nil || lastEv.Usage.TotalTokens != 7 { + t.Errorf("last event Usage.TotalTokens = %v, want 7", lastEv.Usage) + } +} + +func TestReadSSEIntoChannel_ContextCancel(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + + sseData := `data: {"choices":[{"delta":{"content":"first"},"finish_reason":""}]}` + "\n\n" + + ch := make(chan protocoltypes.StreamEvent, 32) + go func() { + defer close(ch) + readSSEIntoChannel(ctx, strings.NewReader(sseData), ch) + }() + + ev := <-ch + if ev.ContentDelta != "first" { + t.Fatalf("ContentDelta = %q, want %q", ev.ContentDelta, "first") + } + + cancel() + _, ok := <-ch + if ok { + t.Fatal("expected channel to be closed after context cancel") + } +} + +func TestAccumulateStream_FullResponse(t *testing.T) { + ch := make(chan protocoltypes.StreamEvent, 8) + + go func() { + ch <- protocoltypes.StreamEvent{ContentDelta: "Hello"} + ch <- protocoltypes.StreamEvent{ContentDelta: " world"} + ch <- protocoltypes.StreamEvent{ + ToolCallDeltas: []protocoltypes.StreamToolCallDelta{ + {Index: 0, ID: "call_1", Name: "test_tool", ArgumentsDelta: `{"key"`}, + }, + } + ch <- protocoltypes.StreamEvent{ + ToolCallDeltas: []protocoltypes.StreamToolCallDelta{ + {Index: 0, ArgumentsDelta: `:"value"}`}, + }, + } + ch <- protocoltypes.StreamEvent{ + FinishReason: "stop", + Usage: &UsageInfo{PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8}, + } + close(ch) + }() + + resp, err := AccumulateStream(ch) + if err != nil { + t.Fatalf("AccumulateStream() error = %v", err) + } + + if resp.Content != "Hello world" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello world") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage == nil || resp.Usage.TotalTokens != 8 { + t.Errorf("Usage.TotalTokens = %v, want 8", resp.Usage) + } + if len(resp.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls)) + } + if resp.ToolCalls[0].Name != "test_tool" { + t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "test_tool") + } + if resp.ToolCalls[0].Arguments["key"] != "value" { + t.Errorf("ToolCalls[0].Arguments[key] = %v, want %q", resp.ToolCalls[0].Arguments["key"], "value") + } +} + +func TestAccumulateStream_Error(t *testing.T) { + ch := make(chan protocoltypes.StreamEvent, 4) + + go func() { + ch <- protocoltypes.StreamEvent{ContentDelta: "partial"} + ch <- protocoltypes.StreamEvent{Err: fmt.Errorf("connection reset")} + close(ch) + }() + + _, err := AccumulateStream(ch) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "connection reset") { + t.Fatalf("error = %q, want to contain %q", err.Error(), "connection reset") + } +} + +func TestChatStream_EndToEnd(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + chunks := []string{ + `data: {"choices":[{"delta":{"content":"stream"},"finish_reason":""}]}`, + `data: {"choices":[{"delta":{"content":"ed"},"finish_reason":""}]}`, + `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`, + `data: [DONE]`, + } + for _, c := range chunks { + fmt.Fprintln(w, c) + fmt.Fprintln(w) + if flusher != nil { + flusher.Flush() + } + } + })) + defer server.Close() + + p := NewProvider("key", server.URL, "", WithStream(true)) + + ch, err := p.ChatStream(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test", nil) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + + resp, err := AccumulateStream(ch) + if err != nil { + t.Fatalf("AccumulateStream() error = %v", err) + } + + if resp.Content != "streamed" { + t.Errorf("Content = %q, want %q", resp.Content, "streamed") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage == nil || resp.Usage.TotalTokens != 3 { + t.Errorf("Usage.TotalTokens = %v, want 3", resp.Usage) + } +} + +func TestChatStream_EarlyCancel(t *testing.T) { + serverDone := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer close(serverDone) + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + flusher, _ := w.(http.Flusher) + + for i := 0; i < 1000; i++ { + select { + case <-r.Context().Done(): + return + default: + } + fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"x\"},\"finish_reason\":\"\"}]}\n\n") + if flusher != nil { + flusher.Flush() + } + } + })) + defer server.Close() + + p := NewProvider("key", server.URL, "", WithStream(true)) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ch, err := p.ChatStream(ctx, []Message{{Role: "user", Content: "hi"}}, nil, "test", nil) + if err != nil { + t.Fatalf("ChatStream() error = %v", err) + } + + count := 0 + for ev := range ch { + if ev.Err != nil { + break + } + count++ + if count >= 5 { + cancel() + } + } + + if count < 5 { + t.Errorf("expected at least 5 events before cancel, got %d", count) + } + + <-serverDone +} + +func TestCanStream(t *testing.T) { + p1 := NewProvider("key", "https://example.com", "") + if p1.CanStream() { + t.Error("CanStream() = true for non-stream provider") + } + + p2 := NewProvider("key", "https://example.com", "", WithStream(true)) + if !p2.CanStream() { + t.Error("CanStream() = false for stream provider") + } +} + +func TestProvider_RequestTimeoutNonPositive(t *testing.T) { + p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", -1) + if p.httpClient.Timeout != defaultRequestTimeout { + t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) + } +} diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index e77d48725..f21318f0f 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -1,9 +1,10 @@ package openai_compat import ( - "context" + "bytes" "encoding/json" "fmt" + "io" "net/http" "net/http/httptest" "net/url" @@ -107,6 +108,55 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) { } } +func TestProviderChat_ParsesToolCallsWithObjectArguments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{ + "content": "", + "tool_calls": []map[string]any{ + { + "id": "call_1", + "type": "function", + "function": map[string]any{ + "name": "get_weather", + "arguments": map[string]any{ + "city": "SF", + "metric": true, + }, + }, + }, + }, + }, + "finish_reason": "tool_calls", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].Name != "get_weather" { + t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") + } + if out.ToolCalls[0].Arguments["city"] != "SF" { + t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"]) + } + if out.ToolCalls[0].Arguments["metric"] != true { + t.Fatalf("ToolCalls[0].Arguments[metric] = %v, want true", out.ToolCalls[0].Arguments["metric"]) + } +} + func TestProviderChat_ParsesReasoningContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ @@ -151,6 +201,56 @@ func TestProviderChat_ParsesReasoningContent(t *testing.T) { } } +func TestProviderChat_PreservesReasoningContentInHistory(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + + // Simulate a multi-turn conversation where the assistant's previous + // reply included reasoning_content (e.g. from kimi-k2.5). + messages := []Message{ + {Role: "user", Content: "What is 1+1?"}, + {Role: "assistant", Content: "2", ReasoningContent: "Let me think... 1+1=2"}, + {Role: "user", Content: "What about 2+2?"}, + } + + _, err := p.Chat(t.Context(), messages, nil, "kimi-k2.5", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Verify reasoning_content is preserved in the serialized request. + reqMessages, ok := requestBody["messages"].([]any) + if !ok { + t.Fatalf("messages is not []any: %T", requestBody["messages"]) + } + assistantMsg, ok := reqMessages[1].(map[string]any) + if !ok { + t.Fatalf("assistant message is not map[string]any: %T", reqMessages[1]) + } + if assistantMsg["reasoning_content"] != "Let me think... 1+1=2" { + t.Errorf("reasoning_content not preserved in request, got %v", assistantMsg["reasoning_content"]) + } +} + func TestProviderChat_HTTPError(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) @@ -164,6 +264,132 @@ func TestProviderChat_HTTPError(t *testing.T) { } } +func TestProviderChat_JSONHTTPErrorDoesNotReportHTML(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"bad request"}`)) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Status: 400") { + t.Fatalf("expected status code in error, got %v", err) + } + if strings.Contains(err.Error(), "returned HTML instead of JSON") { + t.Fatalf("expected non-HTML http error, got %v", err) + } +} + +func TestProviderChat_HTMLResponsesReturnHelpfulError(t *testing.T) { + tests := []struct { + name string + contentType string + statusCode int + body string + }{ + { + name: "html success response", + contentType: "text/html; charset=utf-8", + statusCode: http.StatusOK, + body: "<!DOCTYPE html><html><body>gateway login</body></html>", + }, + { + name: "html error response", + contentType: "text/html; charset=utf-8", + statusCode: http.StatusBadGateway, + body: "<!DOCTYPE html><html><body>bad gateway</body></html>", + }, + { + name: "mislabeled html success response", + contentType: "application/json", + statusCode: http.StatusOK, + body: " \r\n\t<!DOCTYPE html><html><body>gateway login</body></html>", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", tt.contentType) + w.WriteHeader(tt.statusCode) + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), fmt.Sprintf("Status: %d", tt.statusCode)) { + t.Fatalf("expected status code in error, got %v", err) + } + if !strings.Contains(err.Error(), "returned HTML instead of JSON") { + t.Fatalf("expected helpful HTML error, got %v", err) + } + if !strings.Contains(err.Error(), "check api_base or proxy configuration") { + t.Fatalf("expected configuration hint, got %v", err) + } + }) + } +} + +func TestProviderChat_SuccessResponseUsesStreamingDecoder(t *testing.T) { + content := strings.Repeat("a", 1024) + body := `{"choices":[{"message":{"content":"` + content + `"},"finish_reason":"stop"}]}` + + p := NewProvider("key", "https://example.com/v1", "") + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: &errAfterDataReadCloser{ + data: []byte(body), + chunkSize: 64, + }, + }, nil + }), + } + + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if out.Content != content { + t.Fatalf("Content = %q, want %q", out.Content, content) + } +} + +func TestProviderChat_LargeHTMLResponsePreviewIsTruncated(t *testing.T) { + body := append([]byte("<!DOCTYPE html><html><body>"), bytes.Repeat([]byte("A"), 2048)...) + body = append(body, []byte("</body></html>")...) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write(body) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "Body: <!DOCTYPE html><html><body>") { + t.Fatalf("expected html preview in error, got %v", err) + } + if !strings.Contains(err.Error(), "...") { + t.Fatalf("expected truncated preview, got %v", err) + } +} + func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) { var requestBody map[string]any @@ -205,12 +431,17 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin } } -func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { +func TestProviderChat_StripsGroqOllamaDeepseekVivgridPrefixes(t *testing.T) { tests := []struct { name string input string wantModel string }{ + { + name: "strips litellm prefix and preserves proxy model name", + input: "litellm/my-proxy-alias", + wantModel: "my-proxy-alias", + }, { name: "strips groq prefix and keeps nested model", input: "groq/openai/gpt-oss-120b", @@ -226,9 +457,14 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { input: "deepseek/deepseek-chat", wantModel: "deepseek-chat", }, + { + name: "strips vivgrid prefix", + input: "vivgrid/auto", + wantModel: "auto", + }, } - for _, tt := range tests { + for _, tt := range tests { //nolint:dupl t.Run(tt.name, func(t *testing.T) { var requestBody map[string]any @@ -330,393 +566,11 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") } -} - -func TestNormalizeModel_OpenAIPrefix(t *testing.T) { - if got := normalizeModel("openai/gpt-5.2", "https://api.openai.com/v1"); got != "gpt-5.2" { - t.Fatalf("normalizeModel(openai/gpt-5.2) = %q, want %q", got, "gpt-5.2") + if got := normalizeModel("vivgrid/managed", "https://api.vivgrid.com/v1"); got != "managed" { + t.Fatalf("normalizeModel(vivgrid) = %q, want %q", got, "managed") } -} - -func TestProviderChat_StreamingTextResponse(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/text/chatcompletion_v2" { - http.Error(w, "not found", http.StatusNotFound) - return - } - var body map[string]any - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if body["stream"] != true { - t.Error("expected stream=true in request body") - } - - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - flusher, _ := w.(http.Flusher) - - chunks := []string{ - `data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":""}]}`, - `data: {"choices":[{"delta":{"content":" world"},"finish_reason":""}]}`, - `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":5,"completion_tokens":2,"total_tokens":7}}`, - `data: [DONE]`, - } - for _, c := range chunks { - fmt.Fprintln(w, c) - fmt.Fprintln(w) // blank line between events - if flusher != nil { - flusher.Flush() - } - } - })) - defer server.Close() - - p := NewProvider("key", server.URL, "", - WithEndpointPath("/text/chatcompletion_v2"), - WithStream(true), - ) - out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "MiniMax-M1", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - if out.Content != "Hello world" { - t.Fatalf("Content = %q, want %q", out.Content, "Hello world") - } - if out.FinishReason != "stop" { - t.Fatalf("FinishReason = %q, want %q", out.FinishReason, "stop") - } - if out.Usage == nil || out.Usage.TotalTokens != 7 { - t.Fatalf("Usage.TotalTokens = %v, want 7", out.Usage) - } -} - -func TestProviderChat_StreamingToolCalls(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - flusher, _ := w.(http.Flusher) - - chunks := []string{ - `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":""}]}`, - `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"city\":"}}]},"finish_reason":""}]}`, - `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"\"SF\"}"}}]},"finish_reason":""}]}`, - `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":8,"total_tokens":18}}`, - `data: [DONE]`, - } - for _, c := range chunks { - fmt.Fprintln(w, c) - fmt.Fprintln(w) - if flusher != nil { - flusher.Flush() - } - } - })) - defer server.Close() - - p := NewProvider("key", server.URL, "", WithStream(true)) - out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "test", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - if len(out.ToolCalls) != 1 { - t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) - } - tc := out.ToolCalls[0] - if tc.ID != "call_1" { - t.Fatalf("ToolCalls[0].ID = %q, want %q", tc.ID, "call_1") - } - if tc.Name != "get_weather" { - t.Fatalf("ToolCalls[0].Name = %q, want %q", tc.Name, "get_weather") - } - if tc.Arguments["city"] != "SF" { - t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", tc.Arguments["city"]) - } -} - -func TestProviderChat_CustomEndpointPath(t *testing.T) { - var hitPath string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - hitPath = r.URL.Path - resp := map[string]any{ - "choices": []map[string]any{ - {"message": map[string]any{"content": "ok"}, "finish_reason": "stop"}, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - p := NewProvider("key", server.URL, "", - WithEndpointPath("/text/chatcompletion_v2"), - ) - _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - if hitPath != "/text/chatcompletion_v2" { - t.Fatalf("endpoint path = %q, want %q", hitPath, "/text/chatcompletion_v2") - } -} - -func TestReadSSEIntoChannel_TextAndToolCalls(t *testing.T) { - sseData := strings.Join([]string{ - `data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":""}]}`, - ``, - `data: {"choices":[{"delta":{"content":" world"},"finish_reason":""}]}`, - ``, - `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"greet","arguments":"{\"n"}}]},"finish_reason":""}]}`, - ``, - `data: {"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ame\":\"Bob\"}"}}]},"finish_reason":""}]}`, - ``, - `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":4,"total_tokens":7}}`, - ``, - `data: [DONE]`, - ``, - }, "\n") - - ch := make(chan protocoltypes.StreamEvent, 32) - go func() { - defer close(ch) - readSSEIntoChannel(context.Background(), strings.NewReader(sseData), ch) - }() - - var events []protocoltypes.StreamEvent - for ev := range ch { - events = append(events, ev) - } - - if len(events) < 3 { - t.Fatalf("got %d events, want at least 3", len(events)) - } - - // Check content deltas - if events[0].ContentDelta != "Hello" { - t.Errorf("events[0].ContentDelta = %q, want %q", events[0].ContentDelta, "Hello") - } - if events[1].ContentDelta != " world" { - t.Errorf("events[1].ContentDelta = %q, want %q", events[1].ContentDelta, " world") - } - - // Check tool call deltas - if len(events[2].ToolCallDeltas) != 1 || events[2].ToolCallDeltas[0].ID != "call_1" { - t.Errorf("events[2] should contain tool call with ID=call_1") - } - if events[2].ToolCallDeltas[0].Name != "greet" { - t.Errorf("events[2].ToolCallDeltas[0].Name = %q, want %q", events[2].ToolCallDeltas[0].Name, "greet") - } - - // Check finish event - lastEv := events[len(events)-1] - if lastEv.FinishReason != "stop" { - t.Errorf("last event FinishReason = %q, want %q", lastEv.FinishReason, "stop") - } - if lastEv.Usage == nil || lastEv.Usage.TotalTokens != 7 { - t.Errorf("last event Usage.TotalTokens = %v, want 7", lastEv.Usage) - } -} - -func TestReadSSEIntoChannel_ContextCancel(t *testing.T) { - // Simulate a slow SSE stream that gets canceled. - ctx, cancel := context.WithCancel(context.Background()) - - // Create a reader that blocks after sending one chunk. - sseData := `data: {"choices":[{"delta":{"content":"first"},"finish_reason":""}]}` + "\n\n" - - ch := make(chan protocoltypes.StreamEvent, 32) - go func() { - defer close(ch) - readSSEIntoChannel(ctx, strings.NewReader(sseData), ch) - }() - - // Read the first event. - ev := <-ch - if ev.ContentDelta != "first" { - t.Fatalf("ContentDelta = %q, want %q", ev.ContentDelta, "first") - } - - // Cancel the context; the channel should close. - cancel() - _, ok := <-ch - if ok { - t.Fatal("expected channel to be closed after context cancel") - } -} - -func TestAccumulateStream_FullResponse(t *testing.T) { - ch := make(chan protocoltypes.StreamEvent, 8) - - go func() { - ch <- protocoltypes.StreamEvent{ContentDelta: "Hello"} - ch <- protocoltypes.StreamEvent{ContentDelta: " world"} - ch <- protocoltypes.StreamEvent{ - ToolCallDeltas: []protocoltypes.StreamToolCallDelta{ - {Index: 0, ID: "call_1", Name: "test_tool", ArgumentsDelta: `{"key"`}, - }, - } - ch <- protocoltypes.StreamEvent{ - ToolCallDeltas: []protocoltypes.StreamToolCallDelta{ - {Index: 0, ArgumentsDelta: `:"value"}`}, - }, - } - ch <- protocoltypes.StreamEvent{ - FinishReason: "stop", - Usage: &UsageInfo{PromptTokens: 5, CompletionTokens: 3, TotalTokens: 8}, - } - close(ch) - }() - - resp, err := AccumulateStream(ch) - if err != nil { - t.Fatalf("AccumulateStream() error = %v", err) - } - - if resp.Content != "Hello world" { - t.Errorf("Content = %q, want %q", resp.Content, "Hello world") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if resp.Usage == nil || resp.Usage.TotalTokens != 8 { - t.Errorf("Usage.TotalTokens = %v, want 8", resp.Usage) - } - if len(resp.ToolCalls) != 1 { - t.Fatalf("len(ToolCalls) = %d, want 1", len(resp.ToolCalls)) - } - if resp.ToolCalls[0].Name != "test_tool" { - t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "test_tool") - } - if resp.ToolCalls[0].Arguments["key"] != "value" { - t.Errorf("ToolCalls[0].Arguments[key] = %v, want %q", resp.ToolCalls[0].Arguments["key"], "value") - } -} - -func TestAccumulateStream_Error(t *testing.T) { - ch := make(chan protocoltypes.StreamEvent, 4) - - go func() { - ch <- protocoltypes.StreamEvent{ContentDelta: "partial"} - ch <- protocoltypes.StreamEvent{Err: fmt.Errorf("connection reset")} - close(ch) - }() - - _, err := AccumulateStream(ch) - if err == nil { - t.Fatal("expected error, got nil") - } - if !strings.Contains(err.Error(), "connection reset") { - t.Fatalf("error = %q, want to contain %q", err.Error(), "connection reset") - } -} - -func TestChatStream_EndToEnd(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - flusher, _ := w.(http.Flusher) - - chunks := []string{ - `data: {"choices":[{"delta":{"content":"stream"},"finish_reason":""}]}`, - `data: {"choices":[{"delta":{"content":"ed"},"finish_reason":""}]}`, - `data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":2,"completion_tokens":1,"total_tokens":3}}`, - `data: [DONE]`, - } - for _, c := range chunks { - fmt.Fprintln(w, c) - fmt.Fprintln(w) - if flusher != nil { - flusher.Flush() - } - } - })) - defer server.Close() - - p := NewProvider("key", server.URL, "", WithStream(true)) - - ch, err := p.ChatStream(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "test", nil) - if err != nil { - t.Fatalf("ChatStream() error = %v", err) - } - - resp, err := AccumulateStream(ch) - if err != nil { - t.Fatalf("AccumulateStream() error = %v", err) - } - - if resp.Content != "streamed" { - t.Errorf("Content = %q, want %q", resp.Content, "streamed") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if resp.Usage == nil || resp.Usage.TotalTokens != 3 { - t.Errorf("Usage.TotalTokens = %v, want 3", resp.Usage) - } -} - -func TestChatStream_EarlyCancel(t *testing.T) { - serverDone := make(chan struct{}) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - defer close(serverDone) - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - flusher, _ := w.(http.Flusher) - - // Send many chunks; expect the client to cancel early. - for i := 0; i < 1000; i++ { - select { - case <-r.Context().Done(): - return - default: - } - fmt.Fprintf(w, "data: {\"choices\":[{\"delta\":{\"content\":\"x\"},\"finish_reason\":\"\"}]}\n\n") - if flusher != nil { - flusher.Flush() - } - } - })) - defer server.Close() - - p := NewProvider("key", server.URL, "", WithStream(true)) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - ch, err := p.ChatStream(ctx, []Message{{Role: "user", Content: "hi"}}, nil, "test", nil) - if err != nil { - t.Fatalf("ChatStream() error = %v", err) - } - - // Read a few events, then cancel. - count := 0 - for ev := range ch { - if ev.Err != nil { - break - } - count++ - if count >= 5 { - cancel() - } - } - - if count < 5 { - t.Errorf("expected at least 5 events before cancel, got %d", count) - } - - // Server should have received the cancellation. - <-serverDone -} - -func TestCanStream(t *testing.T) { - p1 := NewProvider("key", "https://example.com", "") - if p1.CanStream() { - t.Error("CanStream() = true for non-stream provider") - } - - p2 := NewProvider("key", "https://example.com", "", WithStream(true)) - if !p2.CanStream() { - t.Error("CanStream() = false for stream provider") + if got := normalizeModel("vivgrid/auto", "https://api.vivgrid.com/v1"); got != "auto" { + t.Fatalf("normalizeModel(vivgrid auto) = %q, want %q", got, "auto") } } @@ -734,11 +588,38 @@ func TestProvider_RequestTimeoutOverride(t *testing.T) { } } -func TestProvider_RequestTimeoutNonPositive(t *testing.T) { - p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", -1) - if p.httpClient.Timeout != defaultRequestTimeout { - t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (f roundTripperFunc) RoundTrip(r *http.Request) (*http.Response, error) { + return f(r) +} + +type errAfterDataReadCloser struct { + data []byte + chunkSize int + offset int +} + +func (r *errAfterDataReadCloser) Read(p []byte) (int, error) { + if r.offset >= len(r.data) { + return 0, io.ErrUnexpectedEOF } + + n := r.chunkSize + if n <= 0 || n > len(p) { + n = len(p) + } + remaining := len(r.data) - r.offset + if n > remaining { + n = remaining + } + copy(p, r.data[r.offset:r.offset+n]) + r.offset += n + return n, nil +} + +func (r *errAfterDataReadCloser) Close() error { + return nil } func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) { @@ -761,3 +642,202 @@ func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) { t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } + +func TestSerializeMessages_PlainText(t *testing.T) { + messages := []protocoltypes.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi", ReasoningContent: "thinking..."}, + } + result := serializeMessages(messages) + + data, err := json.Marshal(result) + if err != nil { + t.Fatal(err) + } + + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if msgs[0]["content"] != "hello" { + t.Fatalf("expected plain string content, got %v", msgs[0]["content"]) + } + if msgs[1]["reasoning_content"] != "thinking..." { + t.Fatalf("reasoning_content not preserved, got %v", msgs[1]["reasoning_content"]) + } +} + +func TestSerializeMessages_WithMedia(t *testing.T) { + messages := []protocoltypes.Message{ + {Role: "user", Content: "describe this", Media: []string{"data:image/png;base64,abc123"}}, + } + result := serializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + content, ok := msgs[0]["content"].([]any) + if !ok { + t.Fatalf("expected array content for media message, got %T", msgs[0]["content"]) + } + if len(content) != 2 { + t.Fatalf("expected 2 content parts, got %d", len(content)) + } + + textPart := content[0].(map[string]any) + if textPart["type"] != "text" || textPart["text"] != "describe this" { + t.Fatalf("text part mismatch: %v", textPart) + } + + imgPart := content[1].(map[string]any) + if imgPart["type"] != "image_url" { + t.Fatalf("expected image_url type, got %v", imgPart["type"]) + } + imgURL := imgPart["image_url"].(map[string]any) + if imgURL["url"] != "data:image/png;base64,abc123" { + t.Fatalf("image url mismatch: %v", imgURL["url"]) + } +} + +func TestSerializeMessages_MediaWithToolCallID(t *testing.T) { + messages := []protocoltypes.Message{ + {Role: "tool", Content: "image result", Media: []string{"data:image/png;base64,xyz"}, ToolCallID: "call_1"}, + } + result := serializeMessages(messages) + + data, _ := json.Marshal(result) + var msgs []map[string]any + json.Unmarshal(data, &msgs) + + if msgs[0]["tool_call_id"] != "call_1" { + t.Fatalf("tool_call_id not preserved with media, got %v", msgs[0]["tool_call_id"]) + } + // Content should be multipart array + if _, ok := msgs[0]["content"].([]any); !ok { + t.Fatalf("expected array content, got %T", msgs[0]["content"]) + } +} + +// chatWithCacheKey sets up a test server, sends a Chat request with prompt_cache_key, +// and returns the decoded request body for assertion. +func chatWithCacheKey(t *testing.T, apiBase string) map[string]any { + t.Helper() + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.apiBase = apiBase + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + r.URL, _ = url.Parse(server.URL + r.URL.Path) + return http.DefaultTransport.RoundTrip(r) + }), + } + + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "test-model", + map[string]any{"prompt_cache_key": "agent-main"}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + return requestBody +} + +func TestProviderChat_PromptCacheKeySentToOpenAI(t *testing.T) { + body := chatWithCacheKey(t, "https://api.openai.com/v1") + if body["prompt_cache_key"] != "agent-main" { + t.Fatalf("prompt_cache_key = %v, want %q", body["prompt_cache_key"], "agent-main") + } +} + +func TestProviderChat_PromptCacheKeyOmittedForNonOpenAI(t *testing.T) { + tests := []struct { + name string + apiBase string + }{ + {"mistral", "https://api.mistral.ai/v1"}, + {"gemini", "https://generativelanguage.googleapis.com/v1beta"}, + {"deepseek", "https://api.deepseek.com/v1"}, + {"groq", "https://api.groq.com/openai/v1"}, + {"minimax", "https://api.minimaxi.com/v1"}, + {"ollama_local", "http://localhost:11434/v1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + body := chatWithCacheKey(t, tt.apiBase) + if _, exists := body["prompt_cache_key"]; exists { + t.Fatalf("prompt_cache_key should NOT be sent to %s, but was included in request", tt.name) + } + }) + } +} + +func TestSupportsPromptCacheKey(t *testing.T) { + tests := []struct { + apiBase string + want bool + }{ + {"https://api.openai.com/v1", true}, + {"https://api.openai.com/v1/", true}, + {"https://myresource.openai.azure.com/openai/deployments/gpt-4", true}, + {"https://eastus.openai.azure.com/v1", true}, + {"https://api.mistral.ai/v1", false}, + {"https://generativelanguage.googleapis.com/v1beta", false}, + {"https://api.deepseek.com/v1", false}, + {"https://api.groq.com/openai/v1", false}, + {"http://localhost:11434/v1", false}, + {"https://openrouter.ai/api/v1", false}, + // Edge cases: proxy URLs with openai.com in path should NOT match + {"https://my-proxy.com/api.openai.com/v1", false}, + {"https://proxy.example.com/openai.azure.com/v1", false}, + // Malformed or empty + {"", false}, + {"not-a-url", false}, + } + for _, tt := range tests { + if got := supportsPromptCacheKey(tt.apiBase); got != tt.want { + t.Errorf("supportsPromptCacheKey(%q) = %v, want %v", tt.apiBase, got, tt.want) + } + } +} + +func TestSerializeMessages_StripsSystemParts(t *testing.T) { + messages := []protocoltypes.Message{ + { + Role: "system", + Content: "you are helpful", + SystemParts: []protocoltypes.ContentBlock{ + {Type: "text", Text: "you are helpful"}, + }, + }, + } + result := serializeMessages(messages) + + data, _ := json.Marshal(result) + raw := string(data) + if strings.Contains(raw, "system_parts") { + t.Fatal("system_parts should not appear in serialized output") + } +} diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go index 689107319..6fc81c042 100644 --- a/pkg/providers/tool_call_extract.go +++ b/pkg/providers/tool_call_extract.go @@ -2,8 +2,6 @@ package providers import ( "encoding/json" - "fmt" - "regexp" "strings" ) @@ -60,261 +58,6 @@ func extractToolCallsFromText(text string) []ToolCall { return result } -// --- Shared helpers for XML tool call extraction --- - -// normalizeAlpha keeps only lowercase ASCII letters. -// "tool_call" → "toolcall", "Tool-Call" → "toolcall", "ReadFile" → "readfile". -func normalizeAlpha(s string) string { - var b strings.Builder - for _, r := range s { - if r >= 'A' && r <= 'Z' { - b.WriteRune(r + 32) - } else if r >= 'a' && r <= 'z' { - b.WriteRune(r) - } - } - return b.String() -} - -// levenshtein computes the edit distance between two strings. -// O(n*m) where n,m are string lengths — negligible for short tag names. -func levenshtein(a, b string) int { - la, lb := len(a), len(b) - if la == 0 { - return lb - } - if lb == 0 { - return la - } - prev := make([]int, lb+1) - for j := range prev { - prev[j] = j - } - for i := 1; i <= la; i++ { - curr := make([]int, lb+1) - curr[0] = i - for j := 1; j <= lb; j++ { - cost := 1 - if a[i-1] == b[j-1] { - cost = 0 - } - curr[j] = min(curr[j-1]+1, min(prev[j]+1, prev[j-1]+cost)) - } - prev = curr - } - return prev[lb] -} - -// Known tool call tag patterns (already alpha-normalized). -// Providers may use different names: tool_call, function_call, tool_use, etc. -var toolCallPatterns = []string{"toolcall", "functioncall", "tooluse"} - -// isToolCallTag returns true if the tag name is close to any known tool call -// pattern after alpha normalization + edit distance (threshold ≤ 2). -func isToolCallTag(name string) bool { - const threshold = 2 - norm := normalizeAlpha(name) - for _, pat := range toolCallPatterns { - if levenshtein(norm, pat) <= threshold { - return true - } - } - return false -} - -// tagSuffix returns the part after the last ':' (namespace separator), -// or the whole string if there is no ':'. -func tagSuffix(tag string) string { - if i := strings.LastIndex(tag, ":"); i >= 0 { - return tag[i+1:] - } - return tag -} - -// --- XML block detection via regex --- -// -// Strategy: find <TAG>…</TAG> pairs using regex, then check if the tag -// suffix normalizes to something close to "toolcall" (edit distance ≤ 2). -// Uses greedy (longest) match for the closing tag to capture the full block. - -var ( - reOpenTag = regexp.MustCompile(`<([a-zA-Z][\w:.-]*)>`) - reCloseTag = regexp.MustCompile(`</([a-zA-Z][\w:.-]*)>`) - reBracketMarker = regexp.MustCompile(`\[TOOLCALL\]`) -) - -// findToolCallBlock finds the first XML block whose tag suffix matches -// "toolcall" by edit distance. Returns the block boundaries and the inner -// content, or found=false. -func findToolCallBlock(text string) (blockStart, blockEnd int, content string, found bool) { - for _, om := range reOpenTag.FindAllStringSubmatchIndex(text, -1) { - tagName := text[om[2]:om[3]] - if !isToolCallTag(tagSuffix(tagName)) { - continue - } - // Found a toolcall opening tag. Search for the last matching close tag (greedy). - afterOpen := text[om[1]:] - closes := reCloseTag.FindAllStringSubmatchIndex(afterOpen, -1) - for i := len(closes) - 1; i >= 0; i-- { - closeTagName := afterOpen[closes[i][2]:closes[i][3]] - if isToolCallTag(tagSuffix(closeTagName)) { - return om[0], om[1] + closes[i][1], afterOpen[:closes[i][0]], true - } - } - } - - // Fallback: look for orphaned closing tags (missing opening tag). - // Some LLMs emit the closing </ns:tool_call> without a matching opener. - // Reconstruct the block start from the first <invoke preceding the closer. - for _, cm := range reCloseTag.FindAllStringSubmatchIndex(text, -1) { - closeTagName := text[cm[2]:cm[3]] - if !isToolCallTag(tagSuffix(closeTagName)) { - continue - } - // Found an orphaned toolcall closing tag. Scan backwards for <invoke. - before := text[:cm[0]] - invokePos := strings.LastIndex(before, "<invoke") - if invokePos == -1 { - continue - } - // Also consume a preceding [TOOLCALL] marker if present. - start := invokePos - if loc := reBracketMarker.FindStringIndex(before[:start]); loc != nil && - strings.TrimSpace(before[loc[1]:start]) == "" { - start = loc[0] - } - return start, cm[1], text[invokePos:cm[0]], true - } - - return 0, 0, "", false -} - -// ExtractXMLToolCalls extracts tool calls from XML-formatted text. -// -// Expected format: -// -// <ns:toolcall> -// <invoke name="tool_name"> -// <parameter name="param">value</parameter> -// </invoke> -// </ns:toolcall> -func ExtractXMLToolCalls(text string) []ToolCall { - return extractXMLToolCalls(text) -} - -func extractXMLToolCalls(text string) []ToolCall { - var result []ToolCall - remaining := text - callIdx := 0 - - for { - _, blockEnd, block, found := findToolCallBlock(remaining) - if !found { - break - } - remaining = remaining[blockEnd:] - result = append(result, parseInvokeElements(block, &callIdx)...) - } - - return result -} - -// parseInvokeElements extracts ToolCall entries from <invoke>...</invoke> blocks. -func parseInvokeElements(text string, callIdx *int) []ToolCall { - var result []ToolCall - invokeRemaining := text - for { - invokeStart := strings.Index(invokeRemaining, "<invoke") - if invokeStart == -1 { - break - } - invokeEnd := strings.Index(invokeRemaining[invokeStart:], "</invoke>") - if invokeEnd == -1 { - break - } - invokeBody := invokeRemaining[invokeStart : invokeStart+invokeEnd+len("</invoke>")] - invokeRemaining = invokeRemaining[invokeStart+invokeEnd+len("</invoke>"):] - - // Extract tool name from <invoke name="..."> - nameStart := strings.Index(invokeBody, `name="`) - if nameStart == -1 { - continue - } - nameStart += len(`name="`) - nameEnd := strings.Index(invokeBody[nameStart:], `"`) - if nameEnd == -1 { - continue - } - toolName := invokeBody[nameStart : nameStart+nameEnd] - - // Extract parameters - args := make(map[string]any) - paramRemaining := invokeBody - for { - pStart := strings.Index(paramRemaining, "<parameter") - if pStart == -1 { - break - } - pNameStart := strings.Index(paramRemaining[pStart:], `name="`) - if pNameStart == -1 { - break - } - pNameStart += pStart + len(`name="`) - pNameEnd := strings.Index(paramRemaining[pNameStart:], `"`) - if pNameEnd == -1 { - break - } - paramName := paramRemaining[pNameStart : pNameStart+pNameEnd] - - tagClose := strings.Index(paramRemaining[pNameStart:], ">") - if tagClose == -1 { - break - } - valueStart := pNameStart + tagClose + 1 - valueEnd := strings.Index(paramRemaining[valueStart:], "</parameter>") - if valueEnd == -1 { - break - } - paramValue := paramRemaining[valueStart : valueStart+valueEnd] - args[paramName] = paramValue - paramRemaining = paramRemaining[valueStart+valueEnd+len("</parameter>"):] - } - - *callIdx++ - result = append(result, ToolCall{ - ID: fmt.Sprintf("xmltc_%d", *callIdx), - Type: "function", - Name: toolName, - Arguments: args, - Function: &FunctionCall{ - Name: toolName, - Arguments: cloneToolArgs(args), - }, - }) - } - return result -} - -// StripXMLToolCalls is the exported version for use by the agent loop. -func StripXMLToolCalls(text string) string { - return stripXMLToolCalls(text) -} - -// stripXMLToolCalls removes XML tool call blocks from response text. -// Prevents raw XML tool calls from leaking to users. -func stripXMLToolCalls(text string) string { - blockStart, blockEnd, _, found := findToolCallBlock(text) - if found { - cleaned := text[:blockStart] + text[blockEnd:] - if _, _, _, more := findToolCallBlock(cleaned); more { - cleaned = stripXMLToolCalls(cleaned) - } - return strings.TrimSpace(cleaned) - } - - return strings.TrimSpace(text) -} - // stripToolCallsFromText removes tool call JSON from response text. func stripToolCallsFromText(text string) string { start := strings.Index(text, `{"tool_calls"`) @@ -329,3 +72,16 @@ func stripToolCallsFromText(text string) string { return strings.TrimSpace(text[:start] + text[end:]) } + +// cloneToolArgs returns a shallow copy of the given map so that +// ToolCall.Arguments and FunctionCall.Arguments do not alias. +func cloneToolArgs(m map[string]any) map[string]any { + if m == nil { + return nil + } + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} diff --git a/pkg/providers/tool_call_extract_ext.go b/pkg/providers/tool_call_extract_ext.go new file mode 100644 index 000000000..ffdd5ad5b --- /dev/null +++ b/pkg/providers/tool_call_extract_ext.go @@ -0,0 +1,262 @@ +package providers + +import ( + "fmt" + "regexp" + "strings" +) + +// --- Shared helpers for XML tool call extraction --- + +// normalizeAlpha keeps only lowercase ASCII letters. +// "tool_call" → "toolcall", "Tool-Call" → "toolcall", "ReadFile" → "readfile". +func normalizeAlpha(s string) string { + var b strings.Builder + for _, r := range s { + if r >= 'A' && r <= 'Z' { + b.WriteRune(r + 32) + } else if r >= 'a' && r <= 'z' { + b.WriteRune(r) + } + } + return b.String() +} + +// levenshtein computes the edit distance between two strings. +// O(n*m) where n,m are string lengths — negligible for short tag names. +func levenshtein(a, b string) int { + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + prev := make([]int, lb+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= la; i++ { + curr := make([]int, lb+1) + curr[0] = i + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[j] = min(curr[j-1]+1, min(prev[j]+1, prev[j-1]+cost)) + } + prev = curr + } + return prev[lb] +} + +// Known tool call tag patterns (already alpha-normalized). +// Providers may use different names: tool_call, function_call, tool_use, etc. +var toolCallPatterns = []string{"toolcall", "functioncall", "tooluse"} + +// isToolCallTag returns true if the tag name is close to any known tool call +// pattern after alpha normalization + edit distance (threshold ≤ 2). +func isToolCallTag(name string) bool { + const threshold = 2 + norm := normalizeAlpha(name) + for _, pat := range toolCallPatterns { + if levenshtein(norm, pat) <= threshold { + return true + } + } + return false +} + +// tagSuffix returns the part after the last ':' (namespace separator), +// or the whole string if there is no ':'. +func tagSuffix(tag string) string { + if i := strings.LastIndex(tag, ":"); i >= 0 { + return tag[i+1:] + } + return tag +} + +// --- XML block detection via regex --- +// +// Strategy: find <TAG>…</TAG> pairs using regex, then check if the tag +// suffix normalizes to something close to "toolcall" (edit distance ≤ 2). +// Uses greedy (longest) match for the closing tag to capture the full block. + +var ( + reOpenTag = regexp.MustCompile(`<([a-zA-Z][\w:.-]*)>`) + reCloseTag = regexp.MustCompile(`</([a-zA-Z][\w:.-]*)>`) + reBracketMarker = regexp.MustCompile(`\[TOOLCALL\]`) +) + +// findToolCallBlock finds the first XML block whose tag suffix matches +// "toolcall" by edit distance. Returns the block boundaries and the inner +// content, or found=false. +func findToolCallBlock(text string) (blockStart, blockEnd int, content string, found bool) { + for _, om := range reOpenTag.FindAllStringSubmatchIndex(text, -1) { + tagName := text[om[2]:om[3]] + if !isToolCallTag(tagSuffix(tagName)) { + continue + } + // Found a toolcall opening tag. Search for the last matching close tag (greedy). + afterOpen := text[om[1]:] + closes := reCloseTag.FindAllStringSubmatchIndex(afterOpen, -1) + for i := len(closes) - 1; i >= 0; i-- { + closeTagName := afterOpen[closes[i][2]:closes[i][3]] + if isToolCallTag(tagSuffix(closeTagName)) { + return om[0], om[1] + closes[i][1], afterOpen[:closes[i][0]], true + } + } + } + + // Fallback: look for orphaned closing tags (missing opening tag). + // Some LLMs emit the closing </ns:tool_call> without a matching opener. + // Reconstruct the block start from the first <invoke preceding the closer. + for _, cm := range reCloseTag.FindAllStringSubmatchIndex(text, -1) { + closeTagName := text[cm[2]:cm[3]] + if !isToolCallTag(tagSuffix(closeTagName)) { + continue + } + // Found an orphaned toolcall closing tag. Scan backwards for <invoke. + before := text[:cm[0]] + invokePos := strings.LastIndex(before, "<invoke") + if invokePos == -1 { + continue + } + // Also consume a preceding [TOOLCALL] marker if present. + start := invokePos + if loc := reBracketMarker.FindStringIndex(before[:start]); loc != nil && + strings.TrimSpace(before[loc[1]:start]) == "" { + start = loc[0] + } + return start, cm[1], text[invokePos:cm[0]], true + } + + return 0, 0, "", false +} + +// ExtractXMLToolCalls extracts tool calls from XML-formatted text. +// +// Expected format: +// +// <ns:toolcall> +// <invoke name="tool_name"> +// <parameter name="param">value</parameter> +// </invoke> +// </ns:toolcall> +func ExtractXMLToolCalls(text string) []ToolCall { + return extractXMLToolCalls(text) +} + +func extractXMLToolCalls(text string) []ToolCall { + var result []ToolCall + remaining := text + callIdx := 0 + + for { + _, blockEnd, block, found := findToolCallBlock(remaining) + if !found { + break + } + remaining = remaining[blockEnd:] + result = append(result, parseInvokeElements(block, &callIdx)...) + } + + return result +} + +// parseInvokeElements extracts ToolCall entries from <invoke>...</invoke> blocks. +func parseInvokeElements(text string, callIdx *int) []ToolCall { + var result []ToolCall + invokeRemaining := text + for { + invokeStart := strings.Index(invokeRemaining, "<invoke") + if invokeStart == -1 { + break + } + invokeEnd := strings.Index(invokeRemaining[invokeStart:], "</invoke>") + if invokeEnd == -1 { + break + } + invokeBody := invokeRemaining[invokeStart : invokeStart+invokeEnd+len("</invoke>")] + invokeRemaining = invokeRemaining[invokeStart+invokeEnd+len("</invoke>"):] + + // Extract tool name from <invoke name="..."> + nameStart := strings.Index(invokeBody, `name="`) + if nameStart == -1 { + continue + } + nameStart += len(`name="`) + nameEnd := strings.Index(invokeBody[nameStart:], `"`) + if nameEnd == -1 { + continue + } + toolName := invokeBody[nameStart : nameStart+nameEnd] + + // Extract parameters + args := make(map[string]any) + paramRemaining := invokeBody + for { + pStart := strings.Index(paramRemaining, "<parameter") + if pStart == -1 { + break + } + pNameStart := strings.Index(paramRemaining[pStart:], `name="`) + if pNameStart == -1 { + break + } + pNameStart += pStart + len(`name="`) + pNameEnd := strings.Index(paramRemaining[pNameStart:], `"`) + if pNameEnd == -1 { + break + } + paramName := paramRemaining[pNameStart : pNameStart+pNameEnd] + + tagClose := strings.Index(paramRemaining[pNameStart:], ">") + if tagClose == -1 { + break + } + valueStart := pNameStart + tagClose + 1 + valueEnd := strings.Index(paramRemaining[valueStart:], "</parameter>") + if valueEnd == -1 { + break + } + paramValue := paramRemaining[valueStart : valueStart+valueEnd] + args[paramName] = paramValue + paramRemaining = paramRemaining[valueStart+valueEnd+len("</parameter>"):] + } + + *callIdx++ + result = append(result, ToolCall{ + ID: fmt.Sprintf("xmltc_%d", *callIdx), + Type: "function", + Name: toolName, + Arguments: args, + Function: &FunctionCall{ + Name: toolName, + Arguments: cloneToolArgs(args), + }, + }) + } + return result +} + +// StripXMLToolCalls is the exported version for use by the agent loop. +func StripXMLToolCalls(text string) string { + return stripXMLToolCalls(text) +} + +// stripXMLToolCalls removes XML tool call blocks from response text. +// Prevents raw XML tool calls from leaking to users. +func stripXMLToolCalls(text string) string { + blockStart, blockEnd, _, found := findToolCallBlock(text) + if found { + cleaned := text[:blockStart] + text[blockEnd:] + if _, _, _, more := findToolCallBlock(cleaned); more { + cleaned = stripXMLToolCalls(cleaned) + } + return strings.TrimSpace(cleaned) + } + + return strings.TrimSpace(text) +} diff --git a/pkg/providers/toolcall_utils.go b/pkg/providers/toolcall_utils.go index 8085f815b..9daf18c5a 100644 --- a/pkg/providers/toolcall_utils.go +++ b/pkg/providers/toolcall_utils.go @@ -5,28 +5,66 @@ package providers +import ( + "encoding/json" + "fmt" + "strings" +) + +// buildCLIToolsPrompt creates the tool definitions section for a CLI provider system prompt. +func buildCLIToolsPrompt(tools []ToolDefinition) string { + var sb strings.Builder + + sb.WriteString("## Available Tools\n\n") + sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") + sb.WriteString("```json\n") + sb.WriteString( + `{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`, + ) + sb.WriteString("\n```\n\n") + sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") + sb.WriteString("### Tool Definitions:\n\n") + + for _, tool := range tools { + if tool.Type != "function" { + continue + } + sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) + if tool.Function.Description != "" { + sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) + } + if len(tool.Function.Parameters) > 0 { + paramsJSON, _ := json.Marshal(tool.Function.Parameters) + sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) + } + sb.WriteString("\n") + } + + return sb.String() +} + // NormalizeToolCall normalizes a ToolCall to ensure all fields are properly populated. // It handles cases where Name/Arguments might be in different locations (top-level vs Function) // and ensures both are populated consistently. func NormalizeToolCall(tc ToolCall) ToolCall { normalized := tc - // Ensure Name is populated from Function if not set. + // Ensure Name is populated from Function if not set if normalized.Name == "" && normalized.Function != nil { normalized.Name = normalized.Function.Name } - // Ensure Arguments is not nil. + // Ensure Arguments is not nil if normalized.Arguments == nil { normalized.Arguments = map[string]any{} } - // Populate top-level arguments from Function arguments when needed. + // Parse Arguments from Function.Arguments if not already set if len(normalized.Arguments) == 0 && normalized.Function != nil && len(normalized.Function.Arguments) > 0 { normalized.Arguments = cloneToolArgs(normalized.Function.Arguments) } - // Ensure Function is populated with consistent values. + // Ensure Function is populated with consistent values if normalized.Function == nil { normalized.Function = &FunctionCall{ Name: normalized.Name, @@ -46,14 +84,3 @@ func NormalizeToolCall(tc ToolCall) ToolCall { return normalized } - -func cloneToolArgs(src map[string]any) map[string]any { - if len(src) == 0 { - return map[string]any{} - } - dst := make(map[string]any, len(src)) - for k, v := range src { - dst[k] = v - } - return dst -} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index aad2dccc2..7f289421b 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -18,8 +18,6 @@ type ( ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ExtraContent = protocoltypes.ExtraContent GoogleExtra = protocoltypes.GoogleExtra - StreamEvent = protocoltypes.StreamEvent - StreamToolCallDelta = protocoltypes.StreamToolCallDelta ContentBlock = protocoltypes.ContentBlock CacheControl = protocoltypes.CacheControl ) @@ -84,6 +82,12 @@ func (e *FailoverError) IsRetriable() bool { return e.Reason != FailoverFormat } +// ModelConfig holds primary model and fallback list. +type ModelConfig struct { + Primary string + Fallbacks []string +} + // StreamingProvider extends LLMProvider with SSE channel-based streaming. // Use a type assertion to check if a provider supports streaming: // @@ -97,15 +101,14 @@ type StreamingProvider interface { tools []ToolDefinition, model string, options map[string]any, - ) (<-chan StreamEvent, error) + ) (<-chan protocoltypes.StreamEvent, error) } -// ModelConfig holds primary model and fallback list. -type ModelConfig struct { - Primary string - Fallbacks []string -} - -func MustMarshalParameters(params map[string]any) json.RawMessage { - return protocoltypes.MustMarshalParameters(params) +// UnmarshalArguments is a helper to parse FunctionCall.Arguments from json.RawMessage. +func UnmarshalArguments(raw json.RawMessage) (map[string]any, error) { + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + return m, nil } diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go index 19e6dd390..eab592bec 100644 --- a/pkg/routing/session_key.go +++ b/pkg/routing/session_key.go @@ -42,11 +42,6 @@ func BuildAgentMainSessionKey(agentID string) string { return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey) } -// BuildSubagentSessionKey returns "subagent:<taskID>" for subagent sessions. -func BuildSubagentSessionKey(taskID string) string { - return fmt.Sprintf("subagent:%s", taskID) -} - // BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope. func BuildAgentPeerSessionKey(params SessionKeyParams) string { agentID := NormalizeAgentID(params.AgentID) diff --git a/pkg/routing/session_key_ext.go b/pkg/routing/session_key_ext.go new file mode 100644 index 000000000..62925152a --- /dev/null +++ b/pkg/routing/session_key_ext.go @@ -0,0 +1,8 @@ +package routing + +import "fmt" + +// BuildSubagentSessionKey returns "subagent:<taskID>" for subagent sessions. +func BuildSubagentSessionKey(taskID string) string { + return fmt.Sprintf("subagent:%s", taskID) +} diff --git a/pkg/session/jsonl_backend.go b/pkg/session/jsonl_backend.go new file mode 100644 index 000000000..7f470de15 --- /dev/null +++ b/pkg/session/jsonl_backend.go @@ -0,0 +1,81 @@ +package session + +import ( + "context" + "log" + + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// JSONLBackend adapts a memory.Store into the SessionStore interface. +// Write errors are logged rather than returned, matching the fire-and-forget +// contract of SessionManager that the agent loop relies on. +type JSONLBackend struct { + store memory.Store +} + +// NewJSONLBackend wraps a memory.Store for use as a SessionStore. +func NewJSONLBackend(store memory.Store) *JSONLBackend { + return &JSONLBackend{store: store} +} + +func (b *JSONLBackend) AddMessage(sessionKey, role, content string) { + if err := b.store.AddMessage(context.Background(), sessionKey, role, content); err != nil { + log.Printf("session: add message: %v", err) + } +} + +func (b *JSONLBackend) AddFullMessage(sessionKey string, msg providers.Message) { + if err := b.store.AddFullMessage(context.Background(), sessionKey, msg); err != nil { + log.Printf("session: add full message: %v", err) + } +} + +func (b *JSONLBackend) GetHistory(key string) []providers.Message { + msgs, err := b.store.GetHistory(context.Background(), key) + if err != nil { + log.Printf("session: get history: %v", err) + return []providers.Message{} + } + return msgs +} + +func (b *JSONLBackend) GetSummary(key string) string { + summary, err := b.store.GetSummary(context.Background(), key) + if err != nil { + log.Printf("session: get summary: %v", err) + return "" + } + return summary +} + +func (b *JSONLBackend) SetSummary(key, summary string) { + if err := b.store.SetSummary(context.Background(), key, summary); err != nil { + log.Printf("session: set summary: %v", err) + } +} + +func (b *JSONLBackend) SetHistory(key string, history []providers.Message) { + if err := b.store.SetHistory(context.Background(), key, history); err != nil { + log.Printf("session: set history: %v", err) + } +} + +func (b *JSONLBackend) TruncateHistory(key string, keepLast int) { + if err := b.store.TruncateHistory(context.Background(), key, keepLast); err != nil { + log.Printf("session: truncate history: %v", err) + } +} + +// Save persists session state. Since the JSONL store fsyncs every write +// immediately, the data is already durable. Save runs compaction to reclaim +// space from logically truncated messages (no-op when there are none). +func (b *JSONLBackend) Save(key string) error { + return b.store.Compact(context.Background(), key) +} + +// Close releases resources held by the underlying store. +func (b *JSONLBackend) Close() error { + return b.store.Close() +} diff --git a/pkg/session/jsonl_backend_test.go b/pkg/session/jsonl_backend_test.go new file mode 100644 index 000000000..f5fce98ed --- /dev/null +++ b/pkg/session/jsonl_backend_test.go @@ -0,0 +1,179 @@ +package session_test + +import ( + "fmt" + "testing" + + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" +) + +// Compile-time interface satisfaction checks. +var ( + _ session.LegacyStore = (*session.SessionManager)(nil) + _ session.LegacyStore = (*session.JSONLBackend)(nil) +) + +func newBackend(t *testing.T) *session.JSONLBackend { + t.Helper() + store, err := memory.NewJSONLStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + return session.NewJSONLBackend(store) +} + +func TestJSONLBackend_AddAndGetHistory(t *testing.T) { + b := newBackend(t) + + b.AddMessage("s1", "user", "hello") + b.AddMessage("s1", "assistant", "hi") + + history := b.GetHistory("s1") + if len(history) != 2 { + t.Fatalf("got %d messages, want 2", len(history)) + } + if history[0].Role != "user" || history[0].Content != "hello" { + t.Errorf("msg[0] = %+v", history[0]) + } + if history[1].Role != "assistant" || history[1].Content != "hi" { + t.Errorf("msg[1] = %+v", history[1]) + } +} + +func TestJSONLBackend_AddFullMessage(t *testing.T) { + b := newBackend(t) + + msg := providers.Message{ + Role: "assistant", + Content: "done", + ToolCalls: []providers.ToolCall{ + {ID: "tc1", Function: &providers.FunctionCall{Name: "read_file", Arguments: map[string]any{"path": "x"}}}, + }, + } + b.AddFullMessage("s1", msg) + + history := b.GetHistory("s1") + if len(history) != 1 { + t.Fatalf("got %d, want 1", len(history)) + } + if len(history[0].ToolCalls) != 1 || history[0].ToolCalls[0].ID != "tc1" { + t.Errorf("tool calls = %+v", history[0].ToolCalls) + } +} + +func TestJSONLBackend_Summary(t *testing.T) { + b := newBackend(t) + + if got := b.GetSummary("s1"); got != "" { + t.Errorf("got %q, want empty", got) + } + + b.SetSummary("s1", "test summary") + if got := b.GetSummary("s1"); got != "test summary" { + t.Errorf("got %q, want %q", got, "test summary") + } +} + +func TestJSONLBackend_TruncateAndSave(t *testing.T) { + b := newBackend(t) + + for i := 0; i < 10; i++ { + b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i)) + } + b.TruncateHistory("s1", 3) + + history := b.GetHistory("s1") + if len(history) != 3 { + t.Fatalf("got %d, want 3", len(history)) + } + if history[0].Content != "msg 7" { + t.Errorf("got %q, want %q", history[0].Content, "msg 7") + } + + // Save triggers compaction. + if err := b.Save("s1"); err != nil { + t.Fatal(err) + } + + // Messages still accessible after compaction. + history = b.GetHistory("s1") + if len(history) != 3 { + t.Fatalf("after save: got %d, want 3", len(history)) + } +} + +func TestJSONLBackend_SetHistory(t *testing.T) { + b := newBackend(t) + b.AddMessage("s1", "user", "old") + + b.SetHistory("s1", []providers.Message{ + {Role: "user", Content: "new1"}, + {Role: "assistant", Content: "new2"}, + }) + + history := b.GetHistory("s1") + if len(history) != 2 { + t.Fatalf("got %d, want 2", len(history)) + } + if history[0].Content != "new1" { + t.Errorf("got %q, want %q", history[0].Content, "new1") + } +} + +func TestJSONLBackend_EmptySession(t *testing.T) { + b := newBackend(t) + + history := b.GetHistory("nonexistent") + if history == nil { + t.Fatal("got nil, want empty slice") + } + if len(history) != 0 { + t.Errorf("got %d, want 0", len(history)) + } +} + +func TestJSONLBackend_SessionIsolation(t *testing.T) { + b := newBackend(t) + b.AddMessage("s1", "user", "session1") + b.AddMessage("s2", "user", "session2") + + h1 := b.GetHistory("s1") + h2 := b.GetHistory("s2") + + if len(h1) != 1 || h1[0].Content != "session1" { + t.Errorf("s1: %+v", h1) + } + if len(h2) != 1 || h2[0].Content != "session2" { + t.Errorf("s2: %+v", h2) + } +} + +func TestJSONLBackend_SummarizeFlow(t *testing.T) { + // Simulates the real summarization flow in the agent loop: + // SetSummary → TruncateHistory → Save + b := newBackend(t) + + for i := 0; i < 20; i++ { + b.AddMessage("s1", "user", fmt.Sprintf("msg %d", i)) + } + + b.SetSummary("s1", "conversation about testing") + b.TruncateHistory("s1", 4) + if err := b.Save("s1"); err != nil { + t.Fatal(err) + } + + if got := b.GetSummary("s1"); got != "conversation about testing" { + t.Errorf("summary = %q", got) + } + history := b.GetHistory("s1") + if len(history) != 4 { + t.Fatalf("got %d messages, want 4", len(history)) + } + if history[0].Content != "msg 16" { + t.Errorf("first message = %q, want %q", history[0].Content, "msg 16") + } +} diff --git a/pkg/session/legacy_adapter.go b/pkg/session/legacy_adapter.go index 29e7b4719..d921dc8ba 100644 --- a/pkg/session/legacy_adapter.go +++ b/pkg/session/legacy_adapter.go @@ -598,11 +598,11 @@ func (la *LegacyAdapter) AdvanceStored(key string, delta int) { // Close stops the background flush loop and persists all dirty sessions. -func (la *LegacyAdapter) Close() { +func (la *LegacyAdapter) Close() error { select { case <-la.done: - return // already closed + return nil // already closed default: } @@ -611,7 +611,7 @@ func (la *LegacyAdapter) Close() { la.FlushDirty() - la.store.Close() + return la.store.Close() } func (la *LegacyAdapter) flushLoop() { diff --git a/pkg/session/legacy_adapter_test.go b/pkg/session/legacy_adapter_test.go index b313140cb..13f738225 100644 --- a/pkg/session/legacy_adapter_test.go +++ b/pkg/session/legacy_adapter_test.go @@ -33,7 +33,7 @@ type sessionBackend interface { //nolint:interfacebloat // test helper mirrors S Save(key string) error - Close() + Close() error } func backends(t *testing.T) map[string]sessionBackend { diff --git a/pkg/session/manager.go b/pkg/session/manager.go index 0b6b7b3b6..891907a9e 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -63,25 +63,19 @@ func NewSessionManager(storage string) *SessionManager { func (sm *SessionManager) GetOrCreate(key string) *Session { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[key] - if ok { return session } session = &Session{ - Key: key, - + Key: key, Messages: []providers.Message{}, - - Created: time.Now(), - - Updated: time.Now(), + Created: time.Now(), + Updated: time.Now(), } - sm.sessions[key] = session return session @@ -89,23 +83,18 @@ func (sm *SessionManager) GetOrCreate(key string) *Session { func (sm *SessionManager) AddMessage(sessionKey, role, content string) { sm.AddFullMessage(sessionKey, providers.Message{ - Role: role, - + Role: role, Content: content, }) } // AddFullMessage adds a complete message with tool calls and tool call ID to the session. - // This is used to save the full conversation flow including tool calls and tool results. - func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Message) { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[sessionKey] - if !ok { session = &Session{ Key: sessionKey, @@ -114,77 +103,61 @@ func (sm *SessionManager) AddFullMessage(sessionKey string, msg providers.Messag Created: time.Now(), } - sm.sessions[sessionKey] = session } session.Messages = append(session.Messages, msg) - session.Updated = time.Now() } func (sm *SessionManager) GetHistory(key string) []providers.Message { sm.mu.RLock() - defer sm.mu.RUnlock() session, ok := sm.sessions[key] - if !ok { return []providers.Message{} } history := make([]providers.Message, len(session.Messages)) - copy(history, session.Messages) - return history } func (sm *SessionManager) GetSummary(key string) string { sm.mu.RLock() - defer sm.mu.RUnlock() session, ok := sm.sessions[key] - if !ok { return "" } - return session.Summary } func (sm *SessionManager) SetSummary(key string, summary string) { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[key] - if ok { session.Summary = summary - session.Updated = time.Now() } } func (sm *SessionManager) TruncateHistory(key string, keepLast int) { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[key] - if !ok { return } if keepLast <= 0 { session.Messages = []providers.Message{} - session.Updated = time.Now() - return } @@ -193,22 +166,19 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { } session.Messages = session.Messages[len(session.Messages)-keepLast:] - session.Updated = time.Now() } // sanitizeFilename converts a session key into a cross-platform safe filename. - -// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the - -// volume separator on Windows, so filepath.Base would misinterpret the key. - -// We replace it with '_'. The original key is preserved inside the JSON file, - -// so loadSessions still maps back to the right in-memory key. - +// Replaces ':' with '_' (session key separator) and '/' and '\' with '_' so +// composite IDs (e.g. Telegram forum "chatID/threadID") do not create +// subdirectories or break on Windows. The original key is preserved inside +// the JSON file, so loadSessions still maps back to the right in-memory key. func sanitizeFilename(key string) string { - return strings.ReplaceAll(key, ":", "_") + s := strings.ReplaceAll(key, ":", "_") + s = strings.ReplaceAll(s, "/", "_") + s = strings.ReplaceAll(s, "\\", "_") + return s } func (sm *SessionManager) Save(key string) error { @@ -219,47 +189,32 @@ func (sm *SessionManager) Save(key string) error { filename := sanitizeFilename(key) // filepath.IsLocal rejects empty names, "..", absolute paths, and - - // OS-reserved device names (NUL, COM1 … on Windows). - - // The extra checks reject "." and any directory separators so that - - // the session file is always written directly inside sm.storage. - + // OS-reserved device names (NUL, COM1 ... on Windows). sanitizeFilename + // already replaced '/' and '\' with '_', so no subdirs are created. if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) { return os.ErrInvalid } // Snapshot under read lock, then perform slow file I/O after unlock. - sm.mu.RLock() - stored, ok := sm.sessions[key] - if !ok { sm.mu.RUnlock() - return nil } snapshot := Session{ - Key: stored.Key, - + Key: stored.Key, Summary: stored.Summary, - Created: stored.Created, - Updated: stored.Updated, } - if len(stored.Messages) > 0 { snapshot.Messages = make([]providers.Message, len(stored.Messages)) - copy(snapshot.Messages, stored.Messages) } else { snapshot.Messages = []providers.Message{} } - sm.mu.RUnlock() data, err := json.MarshalIndent(snapshot, "", " ") @@ -268,16 +223,13 @@ func (sm *SessionManager) Save(key string) error { } sessionPath := filepath.Join(sm.storage, filename+".json") - tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp") if err != nil { return err } tmpPath := tmpFile.Name() - cleanup := true - defer func() { if cleanup { _ = os.Remove(tmpPath) @@ -286,22 +238,16 @@ func (sm *SessionManager) Save(key string) error { if _, err := tmpFile.Write(data); err != nil { _ = tmpFile.Close() - return err } - if err := tmpFile.Chmod(0o644); err != nil { _ = tmpFile.Close() - return err } - if err := tmpFile.Sync(); err != nil { _ = tmpFile.Close() - return err } - if err := tmpFile.Close(); err != nil { return err } @@ -309,9 +255,7 @@ func (sm *SessionManager) Save(key string) error { if err := os.Rename(tmpPath, sessionPath); err != nil { return err } - cleanup = false - return nil } @@ -331,14 +275,12 @@ func (sm *SessionManager) loadSessions() error { } sessionPath := filepath.Join(sm.storage, file.Name()) - data, err := os.ReadFile(sessionPath) if err != nil { continue } var session Session - if err := json.Unmarshal(data, &session); err != nil { continue } @@ -456,25 +398,17 @@ func SanitizeHistory(history []providers.Message) ([]providers.Message, int) { } // SetHistory updates the messages of a session. - func (sm *SessionManager) SetHistory(key string, history []providers.Message) { sm.mu.Lock() - defer sm.mu.Unlock() session, ok := sm.sessions[key] - if ok { // Create a deep copy to strictly isolate internal state - // from the caller's slice. - msgs := make([]providers.Message, len(history)) - copy(msgs, history) - session.Messages = msgs - session.Updated = time.Now() } } @@ -513,11 +447,11 @@ func (sm *SessionManager) FlushDirty() { // Close stops the background flush goroutine and writes all dirty sessions. -func (sm *SessionManager) Close() { +func (sm *SessionManager) Close() error { select { case <-sm.done: - return // already closed + return nil // already closed default: } @@ -525,6 +459,7 @@ func (sm *SessionManager) Close() { close(sm.done) sm.FlushDirty() + return nil } func (sm *SessionManager) flushLoop() { diff --git a/pkg/session/manager_ext_test.go b/pkg/session/manager_ext_test.go new file mode 100644 index 000000000..97a262851 --- /dev/null +++ b/pkg/session/manager_ext_test.go @@ -0,0 +1,121 @@ +package session + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestSanitizeHistory_OrphanedToolCall(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "hello"}, + + {Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{ + {ID: "call_1", Name: "exec"}, + + {ID: "call_2", Name: "list_dir"}, + }}, + + {Role: "tool", Content: "ok", ToolCallID: "call_1"}, + } + + sanitized, removed := SanitizeHistory(history) + + if removed == 0 { + t.Fatal("expected orphaned messages to be removed") + } + + if len(sanitized) != 1 || sanitized[0].Role != "user" { + t.Errorf("expected [user], got %d messages", len(sanitized)) + } +} + +func TestSanitizeHistory_InterleavedMessages(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "first"}, + + {Role: "assistant", Content: "ok", ToolCalls: []providers.ToolCall{ + {ID: "call_1", Name: "exec"}, + }}, + + {Role: "user", Content: "collision!"}, + + {Role: "tool", Content: "ok", ToolCallID: "call_1"}, + + {Role: "assistant", Content: "done"}, + } + + sanitized, removed := SanitizeHistory(history) + + if removed == 0 { + t.Fatal("expected interleaved messages to be removed") + } + + if len(sanitized) != 3 { + t.Errorf("expected 3 messages, got %d", len(sanitized)) + + for i, m := range sanitized { + t.Logf(" [%d] role=%s content=%q", i, m.Role, m.Content) + } + } +} + +func TestSanitizeHistory_CleanHistory(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "hello"}, + + {Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{ + {ID: "call_1", Name: "exec"}, + }}, + + {Role: "tool", Content: "ok", ToolCallID: "call_1"}, + + {Role: "assistant", Content: "done"}, + } + + sanitized, removed := SanitizeHistory(history) + + if removed != 0 { + t.Errorf("expected 0 removed, got %d", removed) + } + + if len(sanitized) != 4 { + t.Errorf("expected 4 messages, got %d", len(sanitized)) + } +} + +func TestSanitizeHistory_MultipleToolCalls(t *testing.T) { + history := []providers.Message{ + {Role: "user", Content: "hello"}, + + {Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{ + {ID: "call_1", Name: "exec"}, + + {ID: "call_2", Name: "read_file"}, + }}, + + {Role: "tool", Content: "ok", ToolCallID: "call_1"}, + + {Role: "tool", Content: "content", ToolCallID: "call_2"}, + + {Role: "assistant", Content: "all done"}, + } + + sanitized, removed := SanitizeHistory(history) + + if removed != 0 { + t.Errorf("expected 0 removed, got %d", removed) + } + + if len(sanitized) != 5 { + t.Errorf("expected 5 messages, got %d", len(sanitized)) + } +} + +func TestSanitizeHistory_Empty(t *testing.T) { + sanitized, removed := SanitizeHistory(nil) + + if removed != 0 || sanitized != nil { + t.Errorf("expected nil/0, got %v/%d", sanitized, removed) + } +} diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go index 05b150015..bc5615966 100644 --- a/pkg/session/manager_test.go +++ b/pkg/session/manager_test.go @@ -4,33 +4,25 @@ import ( "os" "path/filepath" "testing" - - "github.com/sipeed/picoclaw/pkg/providers" ) func TestSanitizeFilename(t *testing.T) { tests := []struct { - input string - + input string expected string }{ {"simple", "simple"}, - {"telegram:123456", "telegram_123456"}, - {"discord:987654321", "discord_987654321"}, - {"slack:C01234", "slack_C01234"}, - {"no-colons-here", "no-colons-here"}, - {"multiple:colons:here", "multiple_colons_here"}, + {"agent:main:telegram:group:-1003822706455/12", "agent_main_telegram_group_-1003822706455_12"}, } for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { got := sanitizeFilename(tt.input) - if got != tt.expected { t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected) } @@ -40,185 +32,54 @@ func TestSanitizeFilename(t *testing.T) { func TestSave_WithColonInKey(t *testing.T) { tmpDir := t.TempDir() - sm := NewSessionManager(tmpDir) // Create a session with a key containing colon (typical channel session key). - key := "telegram:123456" - sm.GetOrCreate(key) - sm.AddMessage(key, "user", "hello") // Save should succeed even though the key contains ':' - if err := sm.Save(key); err != nil { t.Fatalf("Save(%q) failed: %v", key, err) } // The file on disk should use sanitized name. - expectedFile := filepath.Join(tmpDir, "telegram_123456.json") - if _, err := os.Stat(expectedFile); os.IsNotExist(err) { t.Fatalf("expected session file %s to exist", expectedFile) } // Load into a fresh manager and verify the session round-trips. - sm2 := NewSessionManager(tmpDir) - history := sm2.GetHistory(key) - if len(history) != 1 { t.Fatalf("expected 1 message after reload, got %d", len(history)) } - if history[0].Content != "hello" { t.Errorf("expected message content %q, got %q", "hello", history[0].Content) } } -func TestSanitizeHistory_OrphanedToolCall(t *testing.T) { - history := []providers.Message{ - {Role: "user", Content: "hello"}, - - {Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{ - {ID: "call_1", Name: "exec"}, - - {ID: "call_2", Name: "list_dir"}, - }}, - - {Role: "tool", Content: "ok", ToolCallID: "call_1"}, - - // Missing tool result for call_2 → orphaned - - } - - sanitized, removed := SanitizeHistory(history) - - if removed == 0 { - t.Fatal("expected orphaned messages to be removed") - } - - // After sanitization, only the user message should remain - - if len(sanitized) != 1 || sanitized[0].Role != "user" { - t.Errorf("expected [user], got %d messages", len(sanitized)) - } -} - -func TestSanitizeHistory_InterleavedMessages(t *testing.T) { - // Simulates session collision: a user message got interleaved between - - // an assistant tool call and its tool result - - history := []providers.Message{ - {Role: "user", Content: "first"}, - - {Role: "assistant", Content: "ok", ToolCalls: []providers.ToolCall{ - {ID: "call_1", Name: "exec"}, - }}, - - {Role: "user", Content: "collision!"}, // ← interleaved from other session - - {Role: "tool", Content: "ok", ToolCallID: "call_1"}, // ← out of order - - {Role: "assistant", Content: "done"}, - } - - sanitized, removed := SanitizeHistory(history) - - if removed == 0 { - t.Fatal("expected interleaved messages to be removed") - } - - // Should keep: user("first"), user("collision!"), assistant("done") - - // Should remove: assistant(call_1), tool(call_1) - - if len(sanitized) != 3 { - t.Errorf("expected 3 messages, got %d", len(sanitized)) - - for i, m := range sanitized { - t.Logf(" [%d] role=%s content=%q", i, m.Role, m.Content) - } - } -} - -func TestSanitizeHistory_CleanHistory(t *testing.T) { - history := []providers.Message{ - {Role: "user", Content: "hello"}, - - {Role: "assistant", Content: "sure", ToolCalls: []providers.ToolCall{ - {ID: "call_1", Name: "exec"}, - }}, - - {Role: "tool", Content: "ok", ToolCallID: "call_1"}, - - {Role: "assistant", Content: "done"}, - } - - sanitized, removed := SanitizeHistory(history) - - if removed != 0 { - t.Errorf("expected 0 removed, got %d", removed) - } - - if len(sanitized) != 4 { - t.Errorf("expected 4 messages, got %d", len(sanitized)) - } -} - -func TestSanitizeHistory_MultipleToolCalls(t *testing.T) { - history := []providers.Message{ - {Role: "user", Content: "hello"}, - - {Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{ - {ID: "call_1", Name: "exec"}, - - {ID: "call_2", Name: "read_file"}, - }}, - - {Role: "tool", Content: "ok", ToolCallID: "call_1"}, - - {Role: "tool", Content: "content", ToolCallID: "call_2"}, - - {Role: "assistant", Content: "all done"}, - } - - sanitized, removed := SanitizeHistory(history) - - if removed != 0 { - t.Errorf("expected 0 removed, got %d", removed) - } - - if len(sanitized) != 5 { - t.Errorf("expected 5 messages, got %d", len(sanitized)) - } -} - -func TestSanitizeHistory_Empty(t *testing.T) { - sanitized, removed := SanitizeHistory(nil) - - if removed != 0 || sanitized != nil { - t.Errorf("expected nil/0, got %v/%d", sanitized, removed) - } -} - func TestSave_RejectsPathTraversal(t *testing.T) { tmpDir := t.TempDir() - sm := NewSessionManager(tmpDir) - badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"} - + // Invalid names that must still be rejected. + badKeys := []string{"", ".", ".."} for _, key := range badKeys { sm.GetOrCreate(key) - if err := sm.Save(key); err == nil { t.Errorf("Save(%q) should have failed but didn't", key) } } + + // Keys containing path separators are sanitized (no subdirs created). + sm.GetOrCreate("foo/bar") + if err := sm.Save("foo/bar"); err != nil { + t.Fatalf("Save(\"foo/bar\") after sanitize should succeed: %v", err) + } + if _, err := os.Stat(filepath.Join(tmpDir, "foo_bar.json")); os.IsNotExist(err) { + t.Errorf("expected foo_bar.json in storage (sanitized from foo/bar)") + } } diff --git a/pkg/session/session_store.go b/pkg/session/session_store.go new file mode 100644 index 000000000..3b22ac85c --- /dev/null +++ b/pkg/session/session_store.go @@ -0,0 +1,32 @@ +package session + +import "github.com/sipeed/picoclaw/pkg/providers" + +// LegacyStore defines the persistence operations used by the agent loop. +// Both SessionManager (legacy JSON backend) and JSONLBackend satisfy this +// interface, allowing the storage layer to be swapped without touching the +// agent loop code. +// +// Write methods (Add*, Set*, Truncate*) are fire-and-forget: they do not +// return errors. Implementations should log failures internally. This +// matches the original SessionManager contract that the agent loop relies on. +type LegacyStore interface { + // AddMessage appends a simple role/content message to the session. + AddMessage(sessionKey, role, content string) + // AddFullMessage appends a complete message including tool calls. + AddFullMessage(sessionKey string, msg providers.Message) + // GetHistory returns the full message history for the session. + GetHistory(key string) []providers.Message + // GetSummary returns the conversation summary, or "" if none. + GetSummary(key string) string + // SetSummary replaces the conversation summary. + SetSummary(key, summary string) + // SetHistory replaces the full message history. + SetHistory(key string, history []providers.Message) + // TruncateHistory keeps only the last keepLast messages. + TruncateHistory(key string, keepLast int) + // Save persists any pending state to durable storage. + Save(key string) error + // Close releases resources held by the store. + Close() error +} diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go index 76e717e4b..bd4bed8fb 100644 --- a/pkg/skills/clawhub_registry.go +++ b/pkg/skills/clawhub_registry.go @@ -8,7 +8,6 @@ import ( "net/http" "net/url" "os" - "strconv" "time" "github.com/sipeed/picoclaw/pkg/utils" @@ -112,7 +111,7 @@ func (c *ClawHubRegistry) Search(ctx context.Context, query string, limit int) ( q := u.Query() q.Set("q", query) if limit > 0 { - q.Set("limit", strconv.Itoa(limit)) + q.Set("limit", fmt.Sprintf("%d", limit)) } u.RawQuery = q.Encode() diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index 71c9d80c5..61f91e82a 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -10,14 +10,15 @@ import ( "regexp" "strings" + "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/ast" + "github.com/gomarkdown/markdown/parser" + "gopkg.in/yaml.v3" + "github.com/sipeed/picoclaw/pkg/logger" ) -var ( - namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) - reFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`) - reStripFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) -) +var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`) const ( MaxNameLength = 64 @@ -231,11 +232,20 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { return nil } - frontmatter := sl.extractFrontmatter(string(content)) + frontmatter, bodyContent := splitFrontmatter(string(content)) + dirName := filepath.Base(filepath.Dir(skillPath)) + title, bodyDescription := extractMarkdownMetadata(bodyContent) + + metadata := &SkillMetadata{ + Name: dirName, + Description: bodyDescription, + } + if title != "" && namePattern.MatchString(title) && len(title) <= MaxNameLength { + metadata.Name = title + } + if frontmatter == "" { - return &SkillMetadata{ - Name: filepath.Base(filepath.Dir(skillPath)), - } + return metadata } // Try JSON first (for backward compatibility) @@ -244,60 +254,133 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { Description string `json:"description"` } if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil { - return &SkillMetadata{ - Name: jsonMeta.Name, - Description: jsonMeta.Description, + if jsonMeta.Name != "" { + metadata.Name = jsonMeta.Name } + if jsonMeta.Description != "" { + metadata.Description = jsonMeta.Description + } + return metadata } // Fall back to simple YAML parsing yamlMeta := sl.parseSimpleYAML(frontmatter) - return &SkillMetadata{ - Name: yamlMeta["name"], - Description: yamlMeta["description"], + if name := yamlMeta["name"]; name != "" { + metadata.Name = name } + if description := yamlMeta["description"]; description != "" { + metadata.Description = description + } + return metadata } -// parseSimpleYAML parses simple key: value YAML format -// Example: name: github\n description: "..." -// Normalizes line endings to handle \n (Unix), \r\n (Windows), and \r (classic Mac) +func extractMarkdownMetadata(content string) (title, description string) { + p := parser.NewWithExtensions(parser.CommonExtensions) + doc := markdown.Parse([]byte(content), p) + if doc == nil { + return "", "" + } + + ast.WalkFunc(doc, func(node ast.Node, entering bool) ast.WalkStatus { + if !entering { + return ast.GoToNext + } + + switch n := node.(type) { + case *ast.Heading: + if title == "" && n.Level == 1 { + title = nodeText(n) + if title != "" && description != "" { + return ast.Terminate + } + } + case *ast.Paragraph: + if description == "" { + description = nodeText(n) + if title != "" && description != "" { + return ast.Terminate + } + } + } + return ast.GoToNext + }) + + return title, description +} + +func nodeText(n ast.Node) string { + var b strings.Builder + ast.WalkFunc(n, func(node ast.Node, entering bool) ast.WalkStatus { + if !entering { + return ast.GoToNext + } + + switch t := node.(type) { + case *ast.Text: + b.Write(t.Literal) + case *ast.Code: + b.Write(t.Literal) + case *ast.Softbreak, *ast.Hardbreak, *ast.NonBlockingSpace: + b.WriteByte(' ') + } + return ast.GoToNext + }) + return strings.Join(strings.Fields(b.String()), " ") +} + +// parseSimpleYAML parses YAML frontmatter and extracts known metadata fields. func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { result := make(map[string]string) - // Normalize line endings: convert \r\n and \r to \n - normalized := strings.ReplaceAll(content, "\r\n", "\n") - normalized = strings.ReplaceAll(normalized, "\r", "\n") - - for line := range strings.SplitSeq(normalized, "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - parts := strings.SplitN(line, ":", 2) - if len(parts) == 2 { - key := strings.TrimSpace(parts[0]) - value := strings.TrimSpace(parts[1]) - // Remove quotes if present - value = strings.Trim(value, "\"'") - result[key] = value - } + var meta struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + } + if err := yaml.Unmarshal([]byte(content), &meta); err != nil { + return result + } + if meta.Name != "" { + result["name"] = meta.Name + } + if meta.Description != "" { + result["description"] = meta.Description } return result } func (sl *SkillsLoader) extractFrontmatter(content string) string { - // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks - match := reFrontmatter.FindStringSubmatch(content) - if len(match) > 1 { - return match[1] - } - return "" + frontmatter, _ := splitFrontmatter(content) + return frontmatter } func (sl *SkillsLoader) stripFrontmatter(content string) string { - return reStripFrontmatter.ReplaceAllString(content, "") + _, body := splitFrontmatter(content) + return body +} + +func splitFrontmatter(content string) (frontmatter, body string) { + normalized := string(parser.NormalizeNewlines([]byte(content))) + lines := strings.Split(normalized, "\n") + if len(lines) == 0 || lines[0] != "---" { + return "", content + } + + end := -1 + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + end = i + break + } + } + if end == -1 { + return "", content + } + + frontmatter = strings.Join(lines[1:end], "\n") + body = strings.Join(lines[end+1:], "\n") + body = strings.TrimLeft(body, "\n") + return frontmatter, body } func escapeXML(s string) string { diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go index 31619f9c2..645d8b7ac 100644 --- a/pkg/skills/loader_test.go +++ b/pkg/skills/loader_test.go @@ -342,3 +342,78 @@ func TestSkillRootsTrimsWhitespaceAndDedups(t *testing.T) { builtin, }, roots) } + +func TestGetSkillMetadata_UsesMarkdownParagraphWhenNoFrontmatter(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "# Plain Skill\n\nThis is parsed from markdown paragraph.\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "plain-skill", meta.Name) + assert.Equal(t, "This is parsed from markdown paragraph.", meta.Description) +} + +func TestGetSkillMetadata_FrontmatterOverridesMarkdown(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "---\nname: frontmatter-skill\ndescription: frontmatter description\n---\n\n# Plain Skill\n\nBody description.\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "frontmatter-skill", meta.Name) + assert.Equal(t, "frontmatter description", meta.Description) +} + +func TestGetSkillMetadata_YAMLMultilineDescription(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "plain-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "---\nname: frontmatter-skill\ndescription: |\n line 1: with colon\n line 2\n---\n\n# Plain Skill\n\nBody description.\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "frontmatter-skill", meta.Name) + assert.Equal(t, "line 1: with colon\nline 2", meta.Description) +} + +func TestGetSkillMetadata_InvalidHeadingNameFallsBackToDirName(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "valid-name") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "# Invalid Heading Name\n\nBody description.\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "valid-name", meta.Name) + assert.Equal(t, "Body description.", meta.Description) +} + +func TestGetSkillMetadata_IgnoresHTMLCommentBlocks(t *testing.T) { + tmp := t.TempDir() + skillDir := filepath.Join(tmp, "workspace", "skills", "biomed-skill") + require.NoError(t, os.MkdirAll(skillDir, 0o755)) + + content := "<!--\n# COPYRIGHT NOTICE\n# This file is part of the \"Universal Biomedical Skills\" project.\n# Copyright (c) 2026 MD BABU MIA, PhD <md.babu.mia@mssm.edu>\n# All Rights Reserved.\n#\n# This code is proprietary and confidential.\n# Unauthorized copying of this file, via any medium is strictly prohibited.\n#\n# Provenance: Authenticated by MD BABU MIA\n\n-->\n\n# Biomed Skill\n\nSummarize biomedical papers.\n" + require.NoError(t, os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(content), 0o644)) + + sl := &SkillsLoader{} + meta := sl.getSkillMetadata(filepath.Join(skillDir, "SKILL.md")) + require.NotNil(t, meta) + assert.Equal(t, "biomed-skill", meta.Name) + assert.Equal(t, "Summarize biomedical papers.", meta.Description) +} diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go index 5cc9ac0a9..45ae72253 100644 --- a/pkg/skills/registry.go +++ b/pkg/skills/registry.go @@ -180,7 +180,7 @@ func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit in close(resultsCh) }() - merged := make([]SearchResult, 0, len(regs)*limit) + var merged []SearchResult var lastErr error var anyRegistrySucceeded bool diff --git a/pkg/skills/search_cache.go b/pkg/skills/search_cache.go index 76a9147d2..1686e3f98 100644 --- a/pkg/skills/search_cache.go +++ b/pkg/skills/search_cache.go @@ -39,8 +39,8 @@ func NewSearchCache(maxEntries int, ttl time.Duration) *SearchCache { ttl = 5 * time.Minute } return &SearchCache{ - entries: make(map[string]*cacheEntry, maxEntries), - order: make([]string, 0, maxEntries), + entries: make(map[string]*cacheEntry), + order: make([]string, 0), maxEntries: maxEntries, ttl: ttl, } diff --git a/pkg/state/state.go b/pkg/state/state.go index 34d1576b6..34589dfae 100644 --- a/pkg/state/state.go +++ b/pkg/state/state.go @@ -48,8 +48,8 @@ func NewManager(workspace string) *Manager { oldStateFile := filepath.Join(workspace, "state.json") // Create state directory if it doesn't exist - if err := os.MkdirAll(stateDir, 0o755); err != nil { - log.Fatalf("[FATAL] state: failed to create state directory: %v", err) + if err := os.MkdirAll(stateDir, 0o700); err != nil { + log.Printf("[WARN] state: failed to create state directory %s: %v", stateDir, err) } sm := &Manager{ @@ -99,36 +99,6 @@ func (sm *Manager) SetLastChannel(channel string) error { return nil } -// SetLastHeartbeatTarget atomically updates the last heartbeat target and saves the state. -func (sm *Manager) SetLastHeartbeatTarget(target string) error { - sm.mu.Lock() - defer sm.mu.Unlock() - - sm.state.LastHeartbeatTarget = target - sm.state.Timestamp = time.Now() - - if err := sm.saveAtomic(); err != nil { - return fmt.Errorf("failed to save state atomically: %w", err) - } - - return nil -} - -// SetHeartbeatTarget atomically updates the explicit heartbeat target and saves the state. -func (sm *Manager) SetHeartbeatTarget(target string) error { - sm.mu.Lock() - defer sm.mu.Unlock() - - sm.state.HeartbeatTarget = target - sm.state.Timestamp = time.Now() - - if err := sm.saveAtomic(); err != nil { - return fmt.Errorf("failed to save state atomically: %w", err) - } - - return nil -} - // SetLastChatID atomically updates the last chat ID and saves the state. func (sm *Manager) SetLastChatID(chatID string) error { sm.mu.Lock() @@ -153,20 +123,6 @@ func (sm *Manager) GetLastChannel() string { return sm.state.LastChannel } -// GetLastHeartbeatTarget returns the last heartbeat target from the state. -func (sm *Manager) GetLastHeartbeatTarget() string { - sm.mu.RLock() - defer sm.mu.RUnlock() - return sm.state.LastHeartbeatTarget -} - -// GetHeartbeatTarget returns the explicit heartbeat target from the state. -func (sm *Manager) GetHeartbeatTarget() string { - sm.mu.RLock() - defer sm.mu.RUnlock() - return sm.state.HeartbeatTarget -} - // GetLastChatID returns the last chat ID from the state. func (sm *Manager) GetLastChatID() string { sm.mu.RLock() @@ -217,3 +173,47 @@ func (sm *Manager) load() error { return nil } + +// SetLastHeartbeatTarget atomically updates the last heartbeat target and saves the state. +func (sm *Manager) SetLastHeartbeatTarget(target string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state.LastHeartbeatTarget = target + sm.state.Timestamp = time.Now() + + if err := sm.saveAtomic(); err != nil { + return fmt.Errorf("failed to save state atomically: %w", err) + } + + return nil +} + +// SetHeartbeatTarget atomically updates the explicit heartbeat target and saves the state. +func (sm *Manager) SetHeartbeatTarget(target string) error { + sm.mu.Lock() + defer sm.mu.Unlock() + + sm.state.HeartbeatTarget = target + sm.state.Timestamp = time.Now() + + if err := sm.saveAtomic(); err != nil { + return fmt.Errorf("failed to save state atomically: %w", err) + } + + return nil +} + +// GetLastHeartbeatTarget returns the last heartbeat target from the state. +func (sm *Manager) GetLastHeartbeatTarget() string { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.LastHeartbeatTarget +} + +// GetHeartbeatTarget returns the explicit heartbeat target from the state. +func (sm *Manager) GetHeartbeatTarget() string { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.state.HeartbeatTarget +} diff --git a/pkg/state/state_ext_test.go b/pkg/state/state_ext_test.go new file mode 100644 index 000000000..ac7e83edb --- /dev/null +++ b/pkg/state/state_ext_test.go @@ -0,0 +1,38 @@ +package state + +import ( + "os" + "testing" +) + +func TestHeartbeatTargetsPersistence(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "state-test-*") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + sm := NewManager(tmpDir) + + if err := sm.SetLastHeartbeatTarget("telegram:-100123"); err != nil { + t.Fatalf("SetLastHeartbeatTarget failed: %v", err) + } + if err := sm.SetHeartbeatTarget("telegram:-100123/42"); err != nil { + t.Fatalf("SetHeartbeatTarget failed: %v", err) + } + + if got := sm.GetLastHeartbeatTarget(); got != "telegram:-100123" { + t.Fatalf("GetLastHeartbeatTarget = %q, want %q", got, "telegram:-100123") + } + if got := sm.GetHeartbeatTarget(); got != "telegram:-100123/42" { + t.Fatalf("GetHeartbeatTarget = %q, want %q", got, "telegram:-100123/42") + } + + sm2 := NewManager(tmpDir) + if got := sm2.GetLastHeartbeatTarget(); got != "telegram:-100123" { + t.Fatalf("persistent GetLastHeartbeatTarget = %q, want %q", got, "telegram:-100123") + } + if got := sm2.GetHeartbeatTarget(); got != "telegram:-100123/42" { + t.Fatalf("persistent GetHeartbeatTarget = %q, want %q", got, "telegram:-100123/42") + } +} diff --git a/pkg/state/state_test.go b/pkg/state/state_test.go index 02d5c6227..3924e5533 100644 --- a/pkg/state/state_test.go +++ b/pkg/state/state_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "os" + "os/exec" "path/filepath" "testing" ) @@ -215,34 +216,31 @@ func TestNewManager_EmptyWorkspace(t *testing.T) { } } -func TestHeartbeatTargetsPersistence(t *testing.T) { - tmpDir, err := os.MkdirTemp("", "state-test-*") +func TestNewManager_MkdirFailureDoesNotCrash(t *testing.T) { + if os.Getenv("BE_CRASHER") == "1" { + tmpDir := os.Getenv("CRASH_DIR") + + statePath := filepath.Join(tmpDir, "state") + if err := os.WriteFile(statePath, []byte("I'm a file, not a folder"), 0o644); err != nil { + fmt.Printf("setup failed: %v", err) + os.Exit(0) + } + + NewManager(tmpDir) + os.Exit(0) + } + + tmpDir, err := os.MkdirTemp("", "state-crash-test-*") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) - sm := NewManager(tmpDir) + cmd := exec.Command(os.Args[0], "-test.run=TestNewManager_MkdirFailureDoesNotCrash") + cmd.Env = append(os.Environ(), "BE_CRASHER=1", "CRASH_DIR="+tmpDir) - if err := sm.SetLastHeartbeatTarget("telegram:-100123"); err != nil { - t.Fatalf("SetLastHeartbeatTarget failed: %v", err) - } - if err := sm.SetHeartbeatTarget("telegram:-100123/42"); err != nil { - t.Fatalf("SetHeartbeatTarget failed: %v", err) - } - - if got := sm.GetLastHeartbeatTarget(); got != "telegram:-100123" { - t.Fatalf("GetLastHeartbeatTarget = %q, want %q", got, "telegram:-100123") - } - if got := sm.GetHeartbeatTarget(); got != "telegram:-100123/42" { - t.Fatalf("GetHeartbeatTarget = %q, want %q", got, "telegram:-100123/42") - } - - sm2 := NewManager(tmpDir) - if got := sm2.GetLastHeartbeatTarget(); got != "telegram:-100123" { - t.Fatalf("persistent GetLastHeartbeatTarget = %q, want %q", got, "telegram:-100123") - } - if got := sm2.GetHeartbeatTarget(); got != "telegram:-100123/42" { - t.Fatalf("persistent GetHeartbeatTarget = %q, want %q", got, "telegram:-100123/42") + err = cmd.Run() + if err != nil { + t.Fatalf("NewManager should not crash when state dir creation fails, got: %v", err) } } diff --git a/pkg/tools/base.go b/pkg/tools/base.go index 04ebe8d7e..fd397ac0e 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -81,13 +81,6 @@ type AsyncExecutor interface { ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult } -// StatusProvider is an optional interface that tools can implement -// to inject runtime status information into the system prompt. -// Return an empty string to inject nothing. -type StatusProvider interface { - RuntimeStatus() string -} - func ToolToSchema(tool Tool) map[string]any { return map[string]any{ "type": "function", @@ -98,3 +91,27 @@ func ToolToSchema(tool Tool) map[string]any { }, } } + +// --- Fork-only extensions below --- + +// ContextualTool is an optional interface for tools that need to know +// which channel/chatID they are executing in. Prefer using ToolChannel(ctx) +// and ToolChatID(ctx) instead — this interface exists for backward compatibility. +type ContextualTool interface { + Tool + SetContext(channel, chatID string) +} + +// AsyncTool is the legacy async interface. Prefer AsyncExecutor instead — +// it passes the callback as a parameter to avoid data races on shared instances. +type AsyncTool interface { + Tool + SetCallback(cb AsyncCallback) +} + +// StatusProvider is an optional interface that tools can implement +// to inject runtime status information into the system prompt. +// Return an empty string to inject nothing. +type StatusProvider interface { + RuntimeStatus() string +} diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 6489cba6e..648cc3c6c 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -4,46 +4,32 @@ import ( "context" "fmt" "strings" - "sync" "time" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/utils" ) // JobExecutor is the interface for executing cron jobs through the agent - type JobExecutor interface { ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) } // CronTool provides scheduling capabilities for the agent - type CronTool struct { cronService *cron.CronService - - executor JobExecutor - - msgBus *bus.MessageBus - - execTool *ExecTool - - channel string - - chatID string - - mu sync.RWMutex + executor JobExecutor + msgBus *bus.MessageBus + execTool *ExecTool } // NewCronTool creates a new CronTool - // execTimeout: 0 means no timeout, >0 sets the timeout duration - func NewCronTool( cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, - execTimeout time.Duration, config *config.Config, ) (*CronTool, error) { execTool, err := NewExecToolWithConfig(workspace, restrict, config) @@ -52,155 +38,103 @@ func NewCronTool( } execTool.SetTimeout(execTimeout) - return &CronTool{ cronService: cronService, - - executor: executor, - - msgBus: msgBus, - - execTool: execTool, + executor: executor, + msgBus: msgBus, + execTool: execTool, }, nil } // Name returns the tool name - func (t *CronTool) Name() string { return "cron" } // Description returns the tool description - func (t *CronTool) Description() string { return "Schedule reminders, tasks, or system commands. IMPORTANT: When user asks to be reminded or scheduled, you MUST call this tool. Use 'at_seconds' for one-time reminders (e.g., 'remind me in 10 minutes' → at_seconds=600). Use 'every_seconds' ONLY for recurring tasks (e.g., 'every 2 hours' → every_seconds=7200). Use 'cron_expr' for complex recurring schedules. Use 'command' to execute shell commands directly." } // Parameters returns the tool parameters schema - func (t *CronTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "action": map[string]any{ - "type": "string", - - "enum": []string{"add", "list", "remove", "enable", "disable"}, - + "type": "string", + "enum": []string{"add", "list", "remove", "enable", "disable"}, "description": "Action to perform. Use 'add' when user wants to schedule a reminder or task.", }, - "message": map[string]any{ - "type": "string", - + "type": "string", "description": "The reminder/task message to display when triggered. If 'command' is used, this describes what the command does.", }, - "command": map[string]any{ - "type": "string", - + "type": "string", "description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.", }, - + "command_confirm": map[string]any{ + "type": "boolean", + "description": "Required when using command=true. Must be true to explicitly confirm scheduling a shell command.", + }, "at_seconds": map[string]any{ - "type": "integer", - + "type": "integer", "description": "One-time reminder: seconds from now when to trigger (e.g., 600 for 10 minutes later). Use this for one-time reminders like 'remind me in 10 minutes'.", }, - "every_seconds": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Recurring interval in seconds (e.g., 3600 for every hour). Use this ONLY for recurring tasks like 'every 2 hours' or 'daily reminder'.", }, - "cron_expr": map[string]any{ - "type": "string", - + "type": "string", "description": "Cron expression for complex recurring schedules (e.g., '0 9 * * *' for daily at 9am). Use this for complex recurring schedules.", }, - "job_id": map[string]any{ - "type": "string", - + "type": "string", "description": "Job ID (for remove/enable/disable)", }, - "deliver": map[string]any{ - "type": "boolean", - + "type": "boolean", "description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: true", }, }, - "required": []string{"action"}, } } -// SetContext sets the current session context for job creation - -func (t *CronTool) SetContext(channel, chatID string) { - t.mu.Lock() - - defer t.mu.Unlock() - - t.channel = channel - - t.chatID = chatID -} - // Execute runs the tool with the given arguments - func (t *CronTool) Execute(ctx context.Context, args map[string]any) *ToolResult { action, ok := args["action"].(string) - if !ok { return ErrorResult("action is required") } switch action { case "add": - - return t.addJob(args) - + return t.addJob(ctx, args) case "list": - return t.listJobs() - case "remove": - return t.removeJob(args) - case "enable": - return t.enableJob(args, true) - case "disable": - return t.enableJob(args, false) - default: - return ErrorResult(fmt.Sprintf("unknown action: %s", action)) } } -func (t *CronTool) addJob(args map[string]any) *ToolResult { - t.mu.RLock() - - channel := t.channel - - chatID := t.chatID - - t.mu.RUnlock() +func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult { + channel := ToolChannel(ctx) + chatID := ToolChatID(ctx) if channel == "" || chatID == "" { return ErrorResult("no session context (channel/chat_id not set). Use this tool in an active conversation.") } message, ok := args["message"].(string) - if !ok || message == "" { return ErrorResult("message is required for add") } @@ -208,35 +142,32 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { var schedule cron.CronSchedule // Check for at_seconds (one-time), every_seconds (recurring), or cron_expr - atSeconds, hasAt := args["at_seconds"].(float64) - everySeconds, hasEvery := args["every_seconds"].(float64) - cronExpr, hasCron := args["cron_expr"].(string) - // Priority: at_seconds > every_seconds > cron_expr + // Fix: type assertions return true for zero values, need additional validity checks + // This prevents LLMs that fill unused optional parameters with defaults (0) from triggering wrong type + hasAt = hasAt && atSeconds > 0 + hasEvery = hasEvery && everySeconds > 0 + hasCron = hasCron && cronExpr != "" + // Priority: at_seconds > every_seconds > cron_expr if hasAt { atMS := time.Now().UnixMilli() + int64(atSeconds)*1000 - schedule = cron.CronSchedule{ Kind: "at", - AtMS: &atMS, } } else if hasEvery { everyMS := int64(everySeconds) * 1000 - schedule = cron.CronSchedule{ - Kind: "every", - + Kind: "every", EveryMS: &everyMS, } } else if hasCron { schedule = cron.CronSchedule{ Kind: "cron", - Expr: cronExpr, } } else { @@ -244,43 +175,34 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { } // Read deliver parameter, default to true - deliver := true - if d, ok := args["deliver"].(bool); ok { deliver = d } + // GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel + explicit confirm. + // Non-command reminders (plain messages) remain open to all channels. command, _ := args["command"].(string) - + commandConfirm, _ := args["command_confirm"].(bool) if command != "" { - // Commands must be processed by agent/exec tool, so deliver must be false (or handled specifically) - - // Actually, let's keep deliver=false to let the system know it's not a simple chat message - - // But for our new logic in ExecuteJob, we can handle it regardless of deliver flag if Payload.Command is set. - - // However, logically, it's not "delivered" to chat directly as is. - + if !constants.IsInternalChannel(channel) { + return ErrorResult("scheduling command execution is restricted to internal channels") + } + if !commandConfirm { + return ErrorResult("command_confirm=true is required to schedule command execution") + } deliver = false } // Truncate message for job name (max 30 chars) - messagePreview := utils.Truncate(message, 30) job, err := t.cronService.AddJob( - messagePreview, - schedule, - message, - deliver, - channel, - chatID, ) if err != nil { @@ -289,9 +211,7 @@ func (t *CronTool) addJob(args map[string]any) *ToolResult { if command != "" { job.Payload.Command = command - // Need to save the updated payload - t.cronService.UpdateJob(job) } @@ -305,13 +225,10 @@ func (t *CronTool) listJobs() *ToolResult { return SilentResult("No scheduled jobs") } - var sb strings.Builder - - sb.WriteString("Scheduled jobs:\n") - + var result strings.Builder + result.WriteString("Scheduled jobs:\n") for _, j := range jobs { var scheduleInfo string - if j.Schedule.Kind == "every" && j.Schedule.EveryMS != nil { scheduleInfo = fmt.Sprintf("every %ds", *j.Schedule.EveryMS/1000) } else if j.Schedule.Kind == "cron" { @@ -321,16 +238,14 @@ func (t *CronTool) listJobs() *ToolResult { } else { scheduleInfo = "unknown" } - - fmt.Fprintf(&sb, "- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo) + result.WriteString(fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)) } - return SilentResult(sb.String()) + return SilentResult(result.String()) } func (t *CronTool) removeJob(args map[string]any) *ToolResult { jobID, ok := args["job_id"].(string) - if !ok || jobID == "" { return ErrorResult("job_id is required for remove") } @@ -338,62 +253,51 @@ func (t *CronTool) removeJob(args map[string]any) *ToolResult { if t.cronService.RemoveJob(jobID) { return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID)) } - return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult { jobID, ok := args["job_id"].(string) - if !ok || jobID == "" { return ErrorResult("job_id is required for enable/disable") } job := t.cronService.EnableJob(jobID, enable) - if job == nil { return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } status := "enabled" - if !enable { status = "disabled" } - return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status)) } // ExecuteJob executes a cron job through the agent - func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { // Get channel/chatID from job payload - channel := job.Payload.Channel - chatID := job.Payload.To // Default values if not set - if channel == "" { channel = "cli" } - if chatID == "" { chatID = "direct" } // Execute command if present - if job.Payload.Command != "" { args := map[string]any{ - "command": job.Payload.Command, + "command": job.Payload.Command, + "__channel": channel, + "__chat_id": chatID, } result := t.execTool.Execute(ctx, args) - var output string - if result.IsError { output = fmt.Sprintf("Error executing scheduled command: %s", result.ForLLM) } else { @@ -401,54 +305,36 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { } pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, - - ChatID: chatID, - + ChatID: chatID, Content: output, }) - return "ok" } // If deliver=true, send message directly without agent processing - if job.Payload.Deliver { pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ Channel: channel, - - ChatID: chatID, - + ChatID: chatID, Content: job.Payload.Message, }) - return "ok" } // For deliver=false, process through agent (for complex tasks) - sessionKey := fmt.Sprintf("cron-%s", job.ID) // Call agent with job's message - response, err := t.executor.ProcessDirectWithChannel( - ctx, - job.Payload.Message, - sessionKey, - channel, - chatID, ) if err != nil { @@ -456,8 +342,6 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string { } // Response is automatically sent via MessageBus by AgentLoop - _ = response // Will be sent by AgentLoop - return "ok" } diff --git a/pkg/tools/cron_test.go b/pkg/tools/cron_test.go new file mode 100644 index 000000000..1776abc65 --- /dev/null +++ b/pkg/tools/cron_test.go @@ -0,0 +1,116 @@ +package tools + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/cron" +) + +func newTestCronTool(t *testing.T) *CronTool { + t.Helper() + storePath := filepath.Join(t.TempDir(), "cron.json") + cronService := cron.NewCronService(storePath, nil) + msgBus := bus.NewMessageBus() + cfg := config.DefaultConfig() + tool, err := NewCronTool(cronService, nil, msgBus, t.TempDir(), true, 0, cfg) + if err != nil { + t.Fatalf("NewCronTool() error: %v", err) + } + return tool +} + +// TestCronTool_CommandBlockedFromRemoteChannel verifies command scheduling is restricted to internal channels +func TestCronTool_CommandBlockedFromRemoteChannel(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "command_confirm": true, + "at_seconds": float64(60), + }) + + if !result.IsError { + t.Fatal("expected command scheduling to be blocked from remote channel") + } + if !strings.Contains(result.ForLLM, "restricted to internal channels") { + t.Errorf("expected 'restricted to internal channels', got: %s", result.ForLLM) + } +} + +// TestCronTool_CommandRequiresConfirm verifies command_confirm=true is required +func TestCronTool_CommandRequiresConfirm(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "at_seconds": float64(60), + }) + + if !result.IsError { + t.Fatal("expected error when command_confirm is missing") + } + if !strings.Contains(result.ForLLM, "command_confirm=true") { + t.Errorf("expected 'command_confirm=true' message, got: %s", result.ForLLM) + } +} + +// TestCronTool_CommandAllowedFromInternalChannel verifies command scheduling works from internal channels +func TestCronTool_CommandAllowedFromInternalChannel(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "check disk", + "command": "df -h", + "command_confirm": true, + "at_seconds": float64(60), + }) + + if result.IsError { + t.Fatalf("expected command scheduling to succeed from internal channel, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "Cron job added") { + t.Errorf("expected 'Cron job added', got: %s", result.ForLLM) + } +} + +// TestCronTool_AddJobRequiresSessionContext verifies fail-closed when channel/chatID missing +func TestCronTool_AddJobRequiresSessionContext(t *testing.T) { + tool := newTestCronTool(t) + result := tool.Execute(context.Background(), map[string]any{ + "action": "add", + "message": "reminder", + "at_seconds": float64(60), + }) + + if !result.IsError { + t.Fatal("expected error when session context is missing") + } + if !strings.Contains(result.ForLLM, "no session context") { + t.Errorf("expected 'no session context' message, got: %s", result.ForLLM) + } +} + +// TestCronTool_NonCommandJobAllowedFromRemoteChannel verifies regular reminders work from any channel +func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) { + tool := newTestCronTool(t) + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{ + "action": "add", + "message": "time to stretch", + "at_seconds": float64(600), + }) + + if result.IsError { + t.Fatalf("expected non-command reminder to succeed from remote channel, got: %s", result.ForLLM) + } +} diff --git a/pkg/tools/edit.go b/pkg/tools/edit.go index 7946fd2fa..d5bebf4a2 100644 --- a/pkg/tools/edit.go +++ b/pkg/tools/edit.go @@ -5,29 +5,23 @@ import ( "errors" "fmt" "io/fs" + "regexp" "strings" ) // EditFileTool edits a file by replacing old_text with new_text. - // The old_text must exist exactly in the file. - type EditFileTool struct { fs fileSystem } // NewEditFileTool creates a new EditFileTool with optional directory restriction. - -func NewEditFileTool(workspace string, restrict bool) *EditFileTool { - var fs fileSystem - - if restrict { - fs = &sandboxFs{workspace: workspace} - } else { - fs = &hostFs{} +func NewEditFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *EditFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] } - - return &EditFileTool{fs: fs} + return &EditFileTool{fs: buildFs(workspace, restrict, patterns)} } func (t *EditFileTool) Name() string { @@ -41,54 +35,43 @@ func (t *EditFileTool) Description() string { func (t *EditFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ - "type": "string", - + "type": "string", "description": "The file path to edit", }, - "old_text": map[string]any{ - "type": "string", - + "type": "string", "description": "The exact text to find and replace", }, - "new_text": map[string]any{ - "type": "string", - + "type": "string", "description": "The text to replace with", }, }, - "required": []string{"path", "old_text", "new_text"}, } } func (t *EditFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { return ErrorResult("path is required") } oldText, ok := args["old_text"].(string) - if !ok { return ErrorResult("old_text is required") } newText, ok := args["new_text"].(string) - if !ok { return ErrorResult("new_text is required") } - if err := editFile(resolveFS(ctx, t.fs, path), path, oldText, newText); err != nil { + if err := editFile(t.fs, path, oldText, newText); err != nil { return ErrorResult(err.Error()) } - return SilentResult(fmt.Sprintf("File edited: %s", path)) } @@ -96,16 +79,12 @@ type AppendFileTool struct { fs fileSystem } -func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool { - var fs fileSystem - - if restrict { - fs = &sandboxFs{workspace: workspace} - } else { - fs = &hostFs{} +func NewAppendFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *AppendFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] } - - return &AppendFileTool{fs: fs} + return &AppendFileTool{fs: buildFs(workspace, restrict, patterns)} } func (t *AppendFileTool) Name() string { @@ -119,49 +98,39 @@ func (t *AppendFileTool) Description() string { func (t *AppendFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ - "type": "string", - + "type": "string", "description": "The file path to append to", }, - "content": map[string]any{ - "type": "string", - + "type": "string", "description": "The content to append", }, }, - "required": []string{"path", "content"}, } } func (t *AppendFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { return ErrorResult("path is required") } content, ok := args["content"].(string) - if !ok { return ErrorResult("content is required") } - if err := appendFile(resolveFS(ctx, t.fs, path), path, content); err != nil { + if err := appendFile(t.fs, path, content); err != nil { return ErrorResult(err.Error()) } - return SilentResult(fmt.Sprintf("Appended to %s", path)) } // editFile reads the file via sysFs, performs the replacement, and writes back. - // It uses a fileSystem interface, allowing the same logic for both restricted and unrestricted modes. - func editFile(sysFs fileSystem, path, oldText, newText string) error { content, err := sysFs.ReadFile(path) if err != nil { @@ -177,21 +146,17 @@ func editFile(sysFs fileSystem, path, oldText, newText string) error { } // appendFile reads the existing content (if any) via sysFs, appends new content, and writes back. - func appendFile(sysFs fileSystem, path, appendContent string) error { content, err := sysFs.ReadFile(path) - if err != nil && !errors.Is(err, fs.ErrNotExist) { return err } newContent := append(content, []byte(appendContent)...) - return sysFs.WriteFile(path, newContent) } // replaceEditContent handles the core logic of finding and replacing a single occurrence of oldText. - func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) { contentStr := string(content) @@ -200,12 +165,10 @@ func replaceEditContent(content []byte, oldText, newText string) ([]byte, error) } count := strings.Count(contentStr, oldText) - if count > 1 { return nil, fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count) } newContent := strings.Replace(contentStr, oldText, newText, 1) - return []byte(newContent), nil } diff --git a/pkg/tools/edit_test.go b/pkg/tools/edit_test.go index ccad894eb..83a7e778c 100644 --- a/pkg/tools/edit_test.go +++ b/pkg/tools/edit_test.go @@ -11,349 +11,261 @@ import ( ) // TestEditTool_EditFile_Success verifies successful file editing - func TestEditTool_EditFile_Success(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("Hello World\nThis is a test"), 0o644) tool := NewEditFileTool(tmpDir, true) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "old_text": "World", - "new_text": "Universe", } result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // Should return SilentResult - if !result.Silent { t.Errorf("Expected Silent=true for EditFile, got false") } // ForUser should be empty (silent result) - if result.ForUser != "" { t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) } // Verify file was actually edited - content, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read edited file: %v", err) } - contentStr := string(content) - if !strings.Contains(contentStr, "Hello Universe") { t.Errorf("Expected file to contain 'Hello Universe', got: %s", contentStr) } - if strings.Contains(contentStr, "Hello World") { t.Errorf("Expected 'Hello World' to be replaced, got: %s", contentStr) } } // TestEditTool_EditFile_NotFound verifies error handling for non-existent file - func TestEditTool_EditFile_NotFound(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "nonexistent.txt") tool := NewEditFileTool(tmpDir, true) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "old_text": "old", - "new_text": "new", } result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error for non-existent file") } // Should mention file not found - if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { t.Errorf("Expected 'file not found' message, got ForLLM: %s", result.ForLLM) } } // TestEditTool_EditFile_OldTextNotFound verifies error when old_text doesn't exist - func TestEditTool_EditFile_OldTextNotFound(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("Hello World"), 0o644) tool := NewEditFileTool(tmpDir, true) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "old_text": "Goodbye", - "new_text": "Hello", } result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when old_text not found") } // Should mention old_text not found - if !strings.Contains(result.ForLLM, "not found") && !strings.Contains(result.ForUser, "not found") { t.Errorf("Expected 'not found' message, got ForLLM: %s", result.ForLLM) } } // TestEditTool_EditFile_MultipleMatches verifies error when old_text appears multiple times - func TestEditTool_EditFile_MultipleMatches(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("test test test"), 0o644) tool := NewEditFileTool(tmpDir, true) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "old_text": "test", - "new_text": "done", } result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when old_text appears multiple times") } // Should mention multiple occurrences - if !strings.Contains(result.ForLLM, "times") && !strings.Contains(result.ForUser, "times") { t.Errorf("Expected 'multiple times' message, got ForLLM: %s", result.ForLLM) } } // TestEditTool_EditFile_OutsideAllowedDir verifies error when path is outside allowed directory - func TestEditTool_EditFile_OutsideAllowedDir(t *testing.T) { tmpDir := t.TempDir() - otherDir := t.TempDir() - testFile := filepath.Join(otherDir, "test.txt") - os.WriteFile(testFile, []byte("content"), 0o644) tool := NewEditFileTool(tmpDir, true) // Restrict to tmpDir - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "old_text": "content", - "new_text": "new", } result := tool.Execute(ctx, args) // Should return error result - assert.True(t, result.IsError, "Expected error when path is outside allowed directory") // Should mention outside allowed directory - // Note: ErrorResult only sets ForLLM by default, so ForUser might be empty. - // We check ForLLM as it's the primary error channel. - assert.True( - t, - strings.Contains(result.ForLLM, "outside") || strings.Contains(result.ForLLM, "access denied") || - strings.Contains(result.ForLLM, "escapes"), - "Expected 'outside allowed' or 'access denied' message, got ForLLM: %s", - result.ForLLM, ) } // TestEditTool_EditFile_MissingPath verifies error handling for missing path - func TestEditTool_EditFile_MissingPath(t *testing.T) { tool := NewEditFileTool("", false) - ctx := context.Background() - args := map[string]any{ "old_text": "old", - "new_text": "new", } result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when path is missing") } } // TestEditTool_EditFile_MissingOldText verifies error handling for missing old_text - func TestEditTool_EditFile_MissingOldText(t *testing.T) { tool := NewEditFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": "/tmp/test.txt", - + "path": "/tmp/test.txt", "new_text": "new", } result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when old_text is missing") } } // TestEditTool_EditFile_MissingNewText verifies error handling for missing new_text - func TestEditTool_EditFile_MissingNewText(t *testing.T) { tool := NewEditFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": "/tmp/test.txt", - + "path": "/tmp/test.txt", "old_text": "old", } result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when new_text is missing") } } // TestEditTool_AppendFile_Success verifies successful file appending - func TestEditTool_AppendFile_Success(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("Initial content"), 0o644) tool := NewAppendFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "content": "\nAppended content", } result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // Should return SilentResult - if !result.Silent { t.Errorf("Expected Silent=true for AppendFile, got false") } // ForUser should be empty (silent result) - if result.ForUser != "" { t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) } // Verify content was actually appended - content, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read file: %v", err) } - contentStr := string(content) - if !strings.Contains(contentStr, "Initial content") { t.Errorf("Expected original content to remain, got: %s", contentStr) } - if !strings.Contains(contentStr, "Appended content") { t.Errorf("Expected appended content, got: %s", contentStr) } } // TestEditTool_AppendFile_MissingPath verifies error handling for missing path - func TestEditTool_AppendFile_MissingPath(t *testing.T) { tool := NewAppendFileTool("", false) - ctx := context.Background() - args := map[string]any{ "content": "test", } @@ -361,19 +273,15 @@ func TestEditTool_AppendFile_MissingPath(t *testing.T) { result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when path is missing") } } // TestEditTool_AppendFile_MissingContent verifies error handling for missing content - func TestEditTool_AppendFile_MissingContent(t *testing.T) { tool := NewAppendFileTool("", false) - ctx := context.Background() - args := map[string]any{ "path": "/tmp/test.txt", } @@ -381,67 +289,43 @@ func TestEditTool_AppendFile_MissingContent(t *testing.T) { result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when content is missing") } } // TestReplaceEditContent verifies the helper function replaceEditContent - func TestReplaceEditContent(t *testing.T) { tests := []struct { - name string - - content []byte - - oldText string - - newText string - - expected []byte - + name string + content []byte + oldText string + newText string + expected []byte expectError bool }{ { - name: "successful replacement", - - content: []byte("hello world"), - - oldText: "world", - - newText: "universe", - - expected: []byte("hello universe"), - + name: "successful replacement", + content: []byte("hello world"), + oldText: "world", + newText: "universe", + expected: []byte("hello universe"), expectError: false, }, - { - name: "old text not found", - - content: []byte("hello world"), - - oldText: "golang", - - newText: "rust", - - expected: nil, - + name: "old text not found", + content: []byte("hello world"), + oldText: "golang", + newText: "rust", + expected: nil, expectError: true, }, - { - name: "multiple matches found", - - content: []byte("test text test"), - - oldText: "test", - - newText: "done", - - expected: nil, - + name: "multiple matches found", + content: []byte("test text test"), + oldText: "test", + newText: "done", + expected: nil, expectError: true, }, } @@ -449,12 +333,10 @@ func TestReplaceEditContent(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := replaceEditContent(tt.content, tt.oldText, tt.newText) - if tt.expectError { assert.Error(t, err) } else { assert.NoError(t, err) - assert.Equal(t, tt.expected, result) } }) @@ -462,142 +344,94 @@ func TestReplaceEditContent(t *testing.T) { } // TestAppendFileTool_AppendToNonExistent_Restricted verifies that AppendFileTool in restricted mode - // can append to a file that does not yet exist — it should silently create the file. - -// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFile + sandboxFs. - +// This exercises the errors.Is(err, fs.ErrNotExist) path in appendFileWithRW + rootRW. func TestAppendFileTool_AppendToNonExistent_Restricted(t *testing.T) { workspace := t.TempDir() - tool := NewAppendFileTool(workspace, true) - ctx := context.Background() args := map[string]any{ - "path": "brand_new_file.txt", - + "path": "brand_new_file.txt", "content": "first content", } result := tool.Execute(ctx, args) - assert.False( - t, - result.IsError, - "Expected success when appending to non-existent file in restricted mode, got: %s", - result.ForLLM, ) // Verify the file was created with correct content - data, err := os.ReadFile(filepath.Join(workspace, "brand_new_file.txt")) - assert.NoError(t, err) - assert.Equal(t, "first content", string(data)) } // TestAppendFileTool_Restricted_Success verifies that AppendFileTool in restricted mode - // correctly appends to an existing file within the sandbox. - func TestAppendFileTool_Restricted_Success(t *testing.T) { workspace := t.TempDir() - testFile := "existing.txt" - err := os.WriteFile(filepath.Join(workspace, testFile), []byte("initial"), 0o644) - assert.NoError(t, err) tool := NewAppendFileTool(workspace, true) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "content": " appended", } result := tool.Execute(ctx, args) - assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) - assert.True(t, result.Silent) data, err := os.ReadFile(filepath.Join(workspace, testFile)) - assert.NoError(t, err) - assert.Equal(t, "initial appended", string(data)) } // TestEditFileTool_Restricted_InPlaceEdit verifies that EditFileTool in restricted mode - -// correctly edits a file using the sandboxFs path. - +// correctly edits a file using the single-open editFileInRoot path. func TestEditFileTool_Restricted_InPlaceEdit(t *testing.T) { workspace := t.TempDir() - testFile := "edit_target.txt" - err := os.WriteFile(filepath.Join(workspace, testFile), []byte("Hello World"), 0o644) - assert.NoError(t, err) tool := NewEditFileTool(workspace, true) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "old_text": "World", - "new_text": "Go", } result := tool.Execute(ctx, args) - assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) - assert.True(t, result.Silent) data, err := os.ReadFile(filepath.Join(workspace, testFile)) - assert.NoError(t, err) - assert.Equal(t, "Hello Go", string(data)) } -// TestEditFileTool_Restricted_FileNotFound verifies that editFile returns a proper - +// TestEditFileTool_Restricted_FileNotFound verifies that editFileInRoot returns a proper // error message when the target file does not exist. - func TestEditFileTool_Restricted_FileNotFound(t *testing.T) { workspace := t.TempDir() - tool := NewEditFileTool(workspace, true) - ctx := context.Background() - args := map[string]any{ - "path": "no_such_file.txt", - + "path": "no_such_file.txt", "old_text": "old", - "new_text": "new", } result := tool.Execute(ctx, args) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "not found") } diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 7b53c5e73..7a165e123 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -2,16 +2,24 @@ package tools import ( "context" + "errors" "fmt" + "io" "io/fs" + "math" "os" "path/filepath" + "regexp" + "strconv" "strings" "time" "github.com/sipeed/picoclaw/pkg/fileutil" + "github.com/sipeed/picoclaw/pkg/logger" ) +const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow + // validatePath ensures the given path is within the workspace if restrict is true. // Used by shell.go for working directory validation. @@ -27,7 +35,6 @@ func validatePath(path, workspace string, restrict bool) (string, error) { } var absPath string - if filepath.IsAbs(path) { absPath = filepath.Clean(path) } else { @@ -43,9 +50,7 @@ func validatePath(path, workspace string, restrict bool) (string, error) { } var resolved string - workspaceReal := absWorkspace - if resolved, err = filepath.EvalSymlinks(absWorkspace); err == nil { workspaceReal = resolved } @@ -56,7 +61,6 @@ func validatePath(path, workspace string, restrict bool) (string, error) { } } else if os.IsNotExist(err) { var parentResolved string - if parentResolved, err = resolveExistingAncestor(filepath.Dir(absPath)); err == nil { if !isWithinWorkspace(parentResolved, workspaceReal) { return "", fmt.Errorf("access denied: symlink resolves outside workspace") @@ -79,7 +83,6 @@ func resolveExistingAncestor(path string) (string, error) { } else if !os.IsNotExist(err) { return "", err } - if filepath.Dir(current) == current { return "", os.ErrNotExist } @@ -88,24 +91,47 @@ func resolveExistingAncestor(path string) (string, error) { func isWithinWorkspace(candidate, workspace string) bool { rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate)) - return err == nil && filepath.IsLocal(rel) } type ReadFileTool struct { - fs fileSystem + fs fileSystem + maxSize int64 } -func NewReadFileTool(workspace string, restrict bool) *ReadFileTool { - var fs fileSystem - - if restrict { - fs = &sandboxFs{workspace: workspace} - } else { - fs = &hostFs{} +func NewReadFileTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] } - return &ReadFileTool{fs: fs} + maxSize := int64(maxReadFileSize) + if maxSize <= 0 { + maxSize = MaxReadFileSize + } + + var fsys fileSystem + + if restrict { + sfs := &sandboxFs{workspace: workspace} + if len(patterns) > 0 { + fsys = &whitelistFs{sandbox: sfs, host: hostFs{}, patterns: patterns} + } else { + fsys = sfs + } + } else { + fsys = &hostFs{} + } + + return &ReadFileTool{ + fs: fsys, + maxSize: maxSize, + } } func (t *ReadFileTool) Name() string { @@ -113,54 +139,232 @@ func (t *ReadFileTool) Name() string { } func (t *ReadFileTool) Description() string { - return "Read the contents of a file" + return "Read the contents of a file. Supports pagination via `offset` and `length`." } func (t *ReadFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ "type": "string", - "description": "Path to the file to read", + "description": "Path to the file to read.", + }, + "offset": map[string]any{ + "type": "integer", + "description": "Byte offset to start reading from.", + "default": 0, + }, + "length": map[string]any{ + "type": "integer", + "description": "Maximum number of bytes to read.", + "default": t.maxSize, }, }, - "required": []string{"path"}, } } func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { return ErrorResult("path is required") } - content, err := resolveFS(ctx, t.fs, path).ReadFile(path) + // offset (optional, default 0) + offset, err := getInt64Arg(args, "offset", 0) if err != nil { return ErrorResult(err.Error()) } + if offset < 0 { + return ErrorResult("offset must be >= 0") + } - return NewToolResult(string(content)) + // length (optional, capped at MaxReadFileSize) + length, err := getInt64Arg(args, "length", t.maxSize) + if err != nil { + return ErrorResult(err.Error()) + } + if length <= 0 { + return ErrorResult("length must be > 0") + } + if length > t.maxSize { + length = t.maxSize + } + + activeFs := resolveFS(ctx, t.fs, path) + + file, err := activeFs.Open(path) + if err != nil { + return ErrorResult(err.Error()) + } + defer file.Close() + + // measure total size + totalSize := int64(-1) // -1 means unknown + if info, statErr := file.Stat(); statErr == nil { + totalSize = info.Size() + } + + // sniff the first 512 bytes to detect binary content before loading + // it into the LLM context. Seeking back to 0 afterwards restores state. + sniff := make([]byte, 512) + sniffN, _ := file.Read(sniff) + + // Reset read position to beginning before applying the caller's offset. + if seeker, ok := file.(io.Seeker); ok { + _, err = seeker.Seek(0, io.SeekStart) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to reset file position after sniff: %v", err)) + } + } else { + // Non-seekable: we consumed sniffN bytes above; account for them when + // discarding to reach the requested offset below. + // If offset < sniffN the data we already read covers it, which we + // cannot replay on a non-seekable stream — return a clear error. + if offset < int64(sniffN) && offset > 0 { + return ErrorResult( + "non-seekable file: cannot seek to an offset within the first 512 bytes after binary detection", + ) + } + } + + // Seek to the requested offset. + if seeker, ok := file.(io.Seeker); ok { + _, err = seeker.Seek(offset, io.SeekStart) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to seek to offset %d: %v", offset, err)) + } + } else if offset > 0 { + // Fallback for non-seekable streams: discard leading bytes. + // sniffN bytes were already consumed above, so subtract them. + remaining := offset - int64(sniffN) + if remaining > 0 { + _, err = io.CopyN(io.Discard, file, remaining) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to advance to offset %d: %v", offset, err)) + } + } + } + + // read length+1 bytes to reliably detect whether more content exists + // without relying on totalSize (which may be -1 for non-seekable streams). + // This avoids the false-positive TRUNCATED message on the last page. + probe := make([]byte, length+1) + n, err := io.ReadFull(file, probe) + // FIX: io.ReadFull returns io.ErrUnexpectedEOF for partial reads (0 < n < len), + // and io.EOF only when n == 0. Both are normal terminal conditions — only + // other errors are genuine failures. + if err != nil && err != io.EOF && !errors.Is(err, io.ErrUnexpectedEOF) { + return ErrorResult(fmt.Sprintf("failed to read file content: %v", err)) + } + + // hasMore is true only when we actually got the extra probe byte. + hasMore := int64(n) > length + data := probe[:min(int64(n), length)] + + if len(data) == 0 { + return NewToolResult("[END OF FILE - no content at this offset]") + } + + // Build metadata header. + // use filepath.Base(path) instead of the raw path to avoid leaking + // internal filesystem structure into the LLM context. + readEnd := offset + int64(len(data)) + // use ASCII hyphen-minus instead of en-dash (U+2013) to keep the + // header parseable by downstream tools and log processors. + readRange := fmt.Sprintf("bytes %d-%d", offset, readEnd-1) + + displayPath := filepath.Base(path) + var header string + if totalSize >= 0 { + header = fmt.Sprintf( + "[file: %s | total: %d bytes | read: %s]", + displayPath, totalSize, readRange, + ) + } else { + header = fmt.Sprintf( + "[file: %s | read: %s | total size unknown]", + displayPath, readRange, + ) + } + + if hasMore { + header += fmt.Sprintf( + "\n[TRUNCATED - file has more content. Call read_file again with offset=%d to continue.]", + readEnd, + ) + } else { + header += "\n[END OF FILE - no further content.]" + } + + logger.DebugCF("tool", "ReadFileTool execution completed successfully", + map[string]any{ + "path": path, + "bytes_read": len(data), + "has_more": hasMore, + }) + + return NewToolResult(header + "\n\n" + string(data)) +} + +// getInt64Arg extracts an integer argument from the args map, returning the +// provided default if the key is absent. +func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { + raw, exists := args[key] + if !exists { + return defaultVal, nil + } + + switch v := raw.(type) { + case float64: + if v != math.Trunc(v) { + return 0, fmt.Errorf("%s must be an integer, got float %v", key, v) + } + if v > math.MaxInt64 || v < math.MinInt64 { + return 0, fmt.Errorf("%s value %v overflows int64", key, v) + } + return int64(v), nil + case int: + return int64(v), nil + case int64: + return v, nil + case string: + parsed, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid integer format for %s parameter: %w", key, err) + } + return parsed, nil + default: + return 0, fmt.Errorf("unsupported type %T for %s parameter", raw, key) + } } type WriteFileTool struct { fs fileSystem } -func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool { - var fs fileSystem - - if restrict { - fs = &sandboxFs{workspace: workspace} - } else { - fs = &hostFs{} +func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] } - return &WriteFileTool{fs: fs} + var fsys fileSystem + + if restrict { + sfs := &sandboxFs{workspace: workspace} + if len(patterns) > 0 { + fsys = &whitelistFs{sandbox: sfs, host: hostFs{}, patterns: patterns} + } else { + fsys = sfs + } + } else { + fsys = &hostFs{} + } + + return &WriteFileTool{fs: fsys} } func (t *WriteFileTool) Name() string { @@ -174,34 +378,29 @@ func (t *WriteFileTool) Description() string { func (t *WriteFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ "type": "string", "description": "Path to the file to write", }, - "content": map[string]any{ "type": "string", "description": "Content to write to the file", }, }, - "required": []string{"path", "content"}, } } func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { return ErrorResult("path is required") } content, ok := args["content"].(string) - if !ok { return ErrorResult("content is required") } @@ -217,16 +416,26 @@ type ListDirTool struct { fs fileSystem } -func NewListDirTool(workspace string, restrict bool) *ListDirTool { - var fs fileSystem - - if restrict { - fs = &sandboxFs{workspace: workspace} - } else { - fs = &hostFs{} +func NewListDirTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *ListDirTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] } - return &ListDirTool{fs: fs} + var fsys fileSystem + + if restrict { + sfs := &sandboxFs{workspace: workspace} + if len(patterns) > 0 { + fsys = &whitelistFs{sandbox: sfs, host: hostFs{}, patterns: patterns} + } else { + fsys = sfs + } + } else { + fsys = &hostFs{} + } + + return &ListDirTool{fs: fsys} } func (t *ListDirTool) Name() string { @@ -240,7 +449,6 @@ func (t *ListDirTool) Description() string { func (t *ListDirTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "path": map[string]any{ "type": "string", @@ -248,14 +456,12 @@ func (t *ListDirTool) Parameters() map[string]any { "description": "Path to list", }, }, - "required": []string{"path"}, } } func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) - if !ok { path = "." } @@ -264,13 +470,11 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]any) *ToolRes if err != nil { return ErrorResult(err.Error()) } - return formatDirEntries(entries) } func formatDirEntries(entries []os.DirEntry) *ToolResult { var result strings.Builder - for _, entry := range entries { if entry.IsDir() { result.WriteString("DIR: ") @@ -282,24 +486,32 @@ func formatDirEntries(entries []os.DirEntry) *ToolResult { result.WriteByte('\n') } - return NewToolResult(result.String()) } +// buildFs returns the appropriate fileSystem implementation based on the +// restrict flag and optional allow-path patterns. +func buildFs(workspace string, restrict bool, patterns []*regexp.Regexp) fileSystem { + if !restrict { + return &hostFs{} + } + sb := &sandboxFs{workspace: workspace} + if len(patterns) > 0 { + return &whitelistFs{sandbox: sb, patterns: patterns} + } + return sb +} + // fileSystem abstracts reading, writing, and listing files, allowing both - // unrestricted (host filesystem) and sandbox (os.Root) implementations to share the same polymorphic interface. - type fileSystem interface { ReadFile(path string) ([]byte, error) - WriteFile(path string, data []byte) error - ReadDir(path string) ([]os.DirEntry, error) + Open(path string) (fs.File, error) } // hostFs is an unrestricted fileReadWriter that operates directly on the host filesystem. - type hostFs struct{} func (h *hostFs) ReadFile(path string) ([]byte, error) { @@ -308,14 +520,11 @@ func (h *hostFs) ReadFile(path string) ([]byte, error) { if os.IsNotExist(err) { return nil, fmt.Errorf("failed to read file: file not found: %w", err) } - if os.IsPermission(err) { return nil, fmt.Errorf("failed to read file: access denied: %w", err) } - return nil, fmt.Errorf("failed to read file: %w", err) } - return content, nil } @@ -330,14 +539,25 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) { func (h *hostFs) WriteFile(path string, data []byte) error { // Use unified atomic write utility with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - return fileutil.WriteFileAtomic(path, data, 0o600) } -// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. +func (h *hostFs) Open(path string) (fs.File, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("failed to open file: file not found: %w", err) + } + if os.IsPermission(err) { + return nil, fmt.Errorf("failed to open file: access denied: %w", err) + } + return nil, fmt.Errorf("failed to open file: %w", err) + } + return f, nil +} +// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. type sandboxFs struct { workspace string } @@ -351,7 +571,6 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) if err != nil { return fmt.Errorf("failed to open workspace: %w", err) } - defer root.Close() relPath, err := getSafeRelPath(r.workspace, path) @@ -364,37 +583,28 @@ func (r *sandboxFs) execute(path string, fn func(root *os.Root, relPath string) func (r *sandboxFs) ReadFile(path string) ([]byte, error) { var content []byte - err := r.execute(path, func(root *os.Root, relPath string) error { fileContent, err := root.ReadFile(relPath) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("failed to read file: file not found: %w", err) } - // os.Root returns "escapes from parent" for paths outside the root - if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || - strings.Contains(err.Error(), "permission denied") { return fmt.Errorf("failed to read file: access denied: %w", err) } - return fmt.Errorf("failed to read file: %w", err) } - content = fileContent - return nil }) - return content, err } func (r *sandboxFs) WriteFile(path string, data []byte) error { return r.execute(path, func(root *os.Root, relPath string) error { dir := filepath.Dir(relPath) - if dir != "." && dir != "/" { if err := root.MkdirAll(dir, 0o755); err != nil { return fmt.Errorf("failed to create parent directories: %w", err) @@ -402,55 +612,42 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error { } // Use atomic write pattern with explicit sync for flash storage reliability. - // Using 0o600 (owner read/write only) for secure default permissions. - tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano()) tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) if err != nil { root.Remove(tmpRelPath) - return fmt.Errorf("failed to open temp file: %w", err) } if _, err := tmpFile.Write(data); err != nil { tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to write temp file: %w", err) } // CRITICAL: Force sync to storage medium before rename. - // This ensures data is physically written to disk, not just cached. - if err := tmpFile.Sync(); err != nil { tmpFile.Close() - root.Remove(tmpRelPath) - return fmt.Errorf("failed to sync temp file: %w", err) } if err := tmpFile.Close(); err != nil { root.Remove(tmpRelPath) - return fmt.Errorf("failed to close temp file: %w", err) } if err := root.Rename(tmpRelPath, relPath); err != nil { root.Remove(tmpRelPath) - return fmt.Errorf("failed to rename temp file over target: %w", err) } // Sync directory to ensure rename is durable - if dirFile, err := root.Open("."); err == nil { _ = dirFile.Sync() - dirFile.Close() } @@ -460,33 +657,91 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error { func (r *sandboxFs) ReadDir(path string) ([]os.DirEntry, error) { var entries []os.DirEntry - err := r.execute(path, func(root *os.Root, relPath string) error { dirEntries, err := fs.ReadDir(root.FS(), relPath) if err != nil { return err } - entries = dirEntries - return nil }) - return entries, err } -// Helper to get a safe relative path for os.Root usage +func (r *sandboxFs) Open(path string) (fs.File, error) { + var f fs.File + err := r.execute(path, func(root *os.Root, relPath string) error { + file, err := root.Open(relPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("failed to open file: file not found: %w", err) + } + if os.IsPermission(err) || strings.Contains(err.Error(), "escapes from parent") || + strings.Contains(err.Error(), "permission denied") { + return fmt.Errorf("failed to open file: access denied: %w", err) + } + return fmt.Errorf("failed to open file: %w", err) + } + f = file + return nil + }) + return f, err +} +// whitelistFs wraps a sandboxFs and allows access to specific paths outside +// the workspace when they match any of the provided patterns. +type whitelistFs struct { + sandbox *sandboxFs + host hostFs + patterns []*regexp.Regexp +} + +func (w *whitelistFs) matches(path string) bool { + for _, p := range w.patterns { + if p.MatchString(path) { + return true + } + } + return false +} + +func (w *whitelistFs) ReadFile(path string) ([]byte, error) { + if w.matches(path) { + return w.host.ReadFile(path) + } + return w.sandbox.ReadFile(path) +} + +func (w *whitelistFs) WriteFile(path string, data []byte) error { + if w.matches(path) { + return w.host.WriteFile(path, data) + } + return w.sandbox.WriteFile(path, data) +} + +func (w *whitelistFs) ReadDir(path string) ([]os.DirEntry, error) { + if w.matches(path) { + return w.host.ReadDir(path) + } + return w.sandbox.ReadDir(path) +} + +func (w *whitelistFs) Open(path string) (fs.File, error) { + if w.matches(path) { + return w.host.Open(path) + } + return w.sandbox.Open(path) +} + +// Helper to get a safe relative path for os.Root usage func getSafeRelPath(workspace, path string) (string, error) { if workspace == "" { return "", fmt.Errorf("workspace is not defined") } rel := filepath.Clean(path) - if filepath.IsAbs(rel) { var err error - rel, err = filepath.Rel(workspace, rel) if err != nil { return "", fmt.Errorf("failed to calculate relative path: %w", err) diff --git a/pkg/tools/filesystem_ext_test.go b/pkg/tools/filesystem_ext_test.go new file mode 100644 index 000000000..1bc676004 --- /dev/null +++ b/pkg/tools/filesystem_ext_test.go @@ -0,0 +1,184 @@ +package tools + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHostFs_Read_PermissionDenied(t *testing.T) { + if os.Getuid() == 0 { + t.Skip("skipping permission test: running as root") + } + + tmpDir := t.TempDir() + + protected := filepath.Join(tmpDir, "protected.txt") + + err := os.WriteFile(protected, []byte("secret"), 0o000) + + assert.NoError(t, err) + + defer os.Chmod(protected, 0o644) + + _, err = (&hostFs{}).ReadFile(protected) + + assert.Error(t, err) + + assert.Contains(t, err.Error(), "access denied") +} + +func TestHostFs_Read_Directory(t *testing.T) { + tmpDir := t.TempDir() + + _, err := (&hostFs{}).ReadFile(tmpDir) + + assert.Error(t, err, "expected error when reading a directory as a file") +} + +func TestSandboxFs_Read_Directory(t *testing.T) { + workspace := t.TempDir() + + root, err := os.OpenRoot(workspace) + + assert.NoError(t, err) + + defer root.Close() + + err = root.Mkdir("subdir", 0o755) + + assert.NoError(t, err) + + _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir") + + assert.Error(t, err, "expected error when reading a directory as a file") +} + +func TestHostFs_Write_ParentDirMissing(t *testing.T) { + tmpDir := t.TempDir() + + target := filepath.Join(tmpDir, "a", "b", "c", "file.txt") + + err := (&hostFs{}).WriteFile(target, []byte("hello")) + + assert.NoError(t, err) + + data, err := os.ReadFile(target) + + assert.NoError(t, err) + + assert.Equal(t, "hello", string(data)) +} + +func TestSandboxFs_Write_ParentDirMissing(t *testing.T) { + workspace := t.TempDir() + + relPath := "x/y/z/file.txt" + + err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested")) + + assert.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(workspace, relPath)) + + assert.NoError(t, err) + + assert.Equal(t, "nested", string(data)) +} + +func TestHostFs_Write(t *testing.T) { + tmpDir := t.TempDir() + + testFile := filepath.Join(tmpDir, "atomic_test.txt") + + testData := []byte("atomic test content") + + err := (&hostFs{}).WriteFile(testFile, testData) + + assert.NoError(t, err) + + content, err := os.ReadFile(testFile) + + assert.NoError(t, err) + + assert.Equal(t, testData, content) + + newData := []byte("new atomic content") + + err = (&hostFs{}).WriteFile(testFile, newData) + + assert.NoError(t, err) + + content, err = os.ReadFile(testFile) + + assert.NoError(t, err) + + assert.Equal(t, newData, content) +} + +func TestSandboxFs_Write(t *testing.T) { //nolint:dupl + tmpDir := t.TempDir() + + relPath := "atomic_root_test.txt" + + testData := []byte("atomic root test content") + + erw := &sandboxFs{workspace: tmpDir} + + err := erw.WriteFile(relPath, testData) + + assert.NoError(t, err) + + root, err := os.OpenRoot(tmpDir) + + assert.NoError(t, err) + + defer root.Close() + + f, err := root.Open(relPath) + + assert.NoError(t, err) + + defer f.Close() + + content, err := io.ReadAll(f) + + assert.NoError(t, err) + + assert.Equal(t, testData, content) + + newData := []byte("new root atomic content") + + err = erw.WriteFile(relPath, newData) + + assert.NoError(t, err) + + f2, err := root.Open(relPath) + + assert.NoError(t, err) + + defer f2.Close() + + content, err = io.ReadAll(f2) + + assert.NoError(t, err) + + assert.Equal(t, newData, content) +} + +func TestValidatePath_OutsideWorkspace_IncludesPath(t *testing.T) { + workspace := t.TempDir() + + outsidePath := filepath.Join(t.TempDir(), "secret.txt") + + _, err := validatePath(outsidePath, workspace, true) + + assert.Error(t, err) + + assert.Contains(t, err.Error(), "access denied") + + assert.Contains(t, err.Error(), workspace) +} diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 456f8fbd3..98cad2561 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -5,6 +5,7 @@ import ( "io" "os" "path/filepath" + "regexp" "strings" "testing" @@ -12,18 +13,13 @@ import ( ) // TestFilesystemTool_ReadFile_Success verifies successful file reading - func TestFilesystemTool_ReadFile_Success(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("test content"), 0o644) - tool := NewReadFileTool("", false) - + tool := NewReadFileTool("", false, MaxReadFileSize) ctx := context.Background() - args := map[string]any{ "path": testFile, } @@ -31,33 +27,26 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForLLM should contain file content - if !strings.Contains(result.ForLLM, "test content") { t.Errorf("Expected ForLLM to contain 'test content', got: %s", result.ForLLM) } // ReadFile returns NewToolResult which only sets ForLLM, not ForUser - // This is the expected behavior - file content goes to LLM, not directly to user - if result.ForUser != "" { t.Errorf("Expected ForUser to be empty for NewToolResult, got: %s", result.ForUser) } } // TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file - func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { - tool := NewReadFileTool("", false) - + tool := NewReadFileTool("", false, MaxReadFileSize) ctx := context.Background() - args := map[string]any{ "path": "/nonexistent_file_12345.txt", } @@ -65,135 +54,107 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { result := tool.Execute(ctx, args) // Failure should be marked as error - if !result.IsError { t.Errorf("Expected error for missing file, got IsError=false") } // Should contain error message - - if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { + if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") { t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } // TestFilesystemTool_ReadFile_MissingPath verifies error handling for missing path - func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { tool := &ReadFileTool{} - ctx := context.Background() - args := map[string]any{} result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when path is missing") } // Should mention required parameter - if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) } } // TestFilesystemTool_WriteFile_Success verifies successful file writing - func TestFilesystemTool_WriteFile_Success(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "newfile.txt") tool := NewWriteFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "content": "hello world", } result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // WriteFile returns SilentResult - if !result.Silent { t.Errorf("Expected Silent=true for WriteFile, got false") } // ForUser should be empty (silent result) - if result.ForUser != "" { t.Errorf("Expected ForUser to be empty for SilentResult, got: %s", result.ForUser) } // Verify file was actually written - content, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read written file: %v", err) } - if string(content) != "hello world" { t.Errorf("Expected file content 'hello world', got: %s", string(content)) } } // TestFilesystemTool_WriteFile_CreateDir verifies directory creation - func TestFilesystemTool_WriteFile_CreateDir(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "subdir", "newfile.txt") tool := NewWriteFileTool("", false) - ctx := context.Background() - args := map[string]any{ - "path": testFile, - + "path": testFile, "content": "test", } result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success with directory creation, got IsError=true: %s", result.ForLLM) } // Verify directory was created and file written - content, err := os.ReadFile(testFile) if err != nil { t.Fatalf("Failed to read written file: %v", err) } - if string(content) != "test" { t.Errorf("Expected file content 'test', got: %s", string(content)) } } // TestFilesystemTool_WriteFile_MissingPath verifies error handling for missing path - func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { tool := NewWriteFileTool("", false) - ctx := context.Background() - args := map[string]any{ "content": "test", } @@ -201,19 +162,15 @@ func TestFilesystemTool_WriteFile_MissingPath(t *testing.T) { result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when path is missing") } } // TestFilesystemTool_WriteFile_MissingContent verifies error handling for missing content - func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { tool := NewWriteFileTool("", false) - ctx := context.Background() - args := map[string]any{ "path": "/tmp/test.txt", } @@ -221,35 +178,26 @@ func TestFilesystemTool_WriteFile_MissingContent(t *testing.T) { result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when content is missing") } // Should mention required parameter - if !strings.Contains(result.ForLLM, "content is required") && - !strings.Contains(result.ForUser, "content is required") { t.Errorf("Expected 'content is required' message, got ForLLM: %s", result.ForLLM) } } // TestFilesystemTool_ListDir_Success verifies successful directory listing - func TestFilesystemTool_ListDir_Success(t *testing.T) { tmpDir := t.TempDir() - os.WriteFile(filepath.Join(tmpDir, "file1.txt"), []byte("content"), 0o644) - os.WriteFile(filepath.Join(tmpDir, "file2.txt"), []byte("content"), 0o644) - os.Mkdir(filepath.Join(tmpDir, "subdir"), 0o755) tool := NewListDirTool("", false) - ctx := context.Background() - args := map[string]any{ "path": tmpDir, } @@ -257,29 +205,23 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // Should list files and directories - if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { t.Errorf("Expected files in listing, got: %s", result.ForLLM) } - if !strings.Contains(result.ForLLM, "subdir") { t.Errorf("Expected subdir in listing, got: %s", result.ForLLM) } } // TestFilesystemTool_ListDir_NotFound verifies error handling for non-existent directory - func TestFilesystemTool_ListDir_NotFound(t *testing.T) { tool := NewListDirTool("", false) - ctx := context.Background() - args := map[string]any{ "path": "/nonexistent_directory_12345", } @@ -287,61 +229,49 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { result := tool.Execute(ctx, args) // Failure should be marked as error - if !result.IsError { t.Errorf("Expected error for non-existent directory, got IsError=false") } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } // TestFilesystemTool_ListDir_DefaultPath verifies default to current directory - func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) { tool := NewListDirTool("", false) - ctx := context.Background() - args := map[string]any{} result := tool.Execute(ctx, args) // Should use "." as default path - if result.IsError { t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM) } } // Block paths that look inside workspace but point outside via symlink. - func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { root := t.TempDir() - workspace := filepath.Join(root, "workspace") - if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatalf("failed to create workspace: %v", err) } secret := filepath.Join(root, "secret.txt") - if err := os.WriteFile(secret, []byte("top secret"), 0o644); err != nil { t.Fatalf("failed to write secret file: %v", err) } link := filepath.Join(workspace, "leak.txt") - if err := os.Symlink(secret, link); err != nil { t.Skipf("symlink not supported in this environment: %v", err) } - tool := NewReadFileTool(workspace, true) - + tool := NewReadFileTool(workspace, true, MaxReadFileSize) result := tool.Execute(context.Background(), map[string]any{ "path": link, }) @@ -349,29 +279,21 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { if !result.IsError { t.Fatalf("expected symlink escape to be blocked") } - // os.Root might return different errors depending on platform/implementation - // but it definitely should error. - // Our wrapper returns "access denied or file not found" - if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && - !strings.Contains(result.ForLLM, "no such file") { t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) } } func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { - tool := NewReadFileTool("", true) // restrict=true but workspace="" + tool := NewReadFileTool("", true, MaxReadFileSize) // restrict=true but workspace="" // Try to read a sensitive file (simulated by a temp file outside workspace) - tmpDir := t.TempDir() - secretFile := filepath.Join(tmpDir, "shadow") - os.WriteFile(secretFile, []byte("secret data"), 0o600) result := tool.Execute(context.Background(), map[string]any{ @@ -379,293 +301,346 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { }) // We EXPECT IsError=true (access blocked due to empty workspace) - assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM) // Verify it failed for the right reason - assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") } -// TestRootMkdirAll verifies that root.MkdirAll (used by sandboxFs.WriteFile) handles all cases: - +// TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: // single dir, deeply nested dirs, already-existing dirs, and a file blocking a directory path. - func TestRootMkdirAll(t *testing.T) { workspace := t.TempDir() - root, err := os.OpenRoot(workspace) if err != nil { t.Fatalf("failed to open root: %v", err) } - defer root.Close() // Case 1: Single directory - err = root.MkdirAll("dir1", 0o755) - assert.NoError(t, err) - _, err = os.Stat(filepath.Join(workspace, "dir1")) - assert.NoError(t, err) // Case 2: Deeply nested directory - err = root.MkdirAll("a/b/c/d", 0o755) - assert.NoError(t, err) - _, err = os.Stat(filepath.Join(workspace, "a/b/c/d")) - assert.NoError(t, err) // Case 3: Already exists — must be idempotent - err = root.MkdirAll("a/b/c/d", 0o755) - assert.NoError(t, err) // Case 4: A regular file blocks directory creation — must error - err = os.WriteFile(filepath.Join(workspace, "file_exists"), []byte("data"), 0o644) - assert.NoError(t, err) - err = root.MkdirAll("file_exists", 0o755) - assert.Error(t, err, "expected error when a file exists at the directory path") } func TestFilesystemTool_WriteFile_Restricted_CreateDir(t *testing.T) { workspace := t.TempDir() - tool := NewWriteFileTool(workspace, true) - ctx := context.Background() testFile := "deep/nested/path/to/file.txt" - content := "deep content" - args := map[string]any{ - "path": testFile, - + "path": testFile, "content": content, } result := tool.Execute(ctx, args) - assert.False(t, result.IsError, "Expected success, got: %s", result.ForLLM) // Verify file content - actualPath := filepath.Join(workspace, testFile) - data, err := os.ReadFile(actualPath) - assert.NoError(t, err) - assert.Equal(t, content, string(data)) } -// TestHostFs_Read_PermissionDenied verifies that hostFs.ReadFile surfaces access denied errors. - -func TestHostFs_Read_PermissionDenied(t *testing.T) { +// TestHostRW_Read_PermissionDenied verifies that hostRW.Read surfaces access denied errors. +func TestHostRW_Read_PermissionDenied(t *testing.T) { if os.Getuid() == 0 { t.Skip("skipping permission test: running as root") } - tmpDir := t.TempDir() - protected := filepath.Join(tmpDir, "protected.txt") - err := os.WriteFile(protected, []byte("secret"), 0o000) - assert.NoError(t, err) - defer os.Chmod(protected, 0o644) // ensure cleanup _, err = (&hostFs{}).ReadFile(protected) - assert.Error(t, err) - assert.Contains(t, err.Error(), "access denied") } -// TestHostFs_Read_Directory verifies that hostFs.ReadFile returns an error when given a directory path. - -func TestHostFs_Read_Directory(t *testing.T) { +// TestHostRW_Read_Directory verifies that hostRW.Read returns an error when given a directory path. +func TestHostRW_Read_Directory(t *testing.T) { tmpDir := t.TempDir() _, err := (&hostFs{}).ReadFile(tmpDir) - assert.Error(t, err, "expected error when reading a directory as a file") } -// TestSandboxFs_Read_Directory verifies that sandboxFs.ReadFile returns an error when given a directory. - -func TestSandboxFs_Read_Directory(t *testing.T) { +// TestRootRW_Read_Directory verifies that rootRW.Read returns an error when given a directory. +func TestRootRW_Read_Directory(t *testing.T) { workspace := t.TempDir() - root, err := os.OpenRoot(workspace) - assert.NoError(t, err) - defer root.Close() // Create a subdirectory - err = root.Mkdir("subdir", 0o755) - assert.NoError(t, err) _, err = (&sandboxFs{workspace: workspace}).ReadFile("subdir") - assert.Error(t, err, "expected error when reading a directory as a file") } -// TestHostFs_Write_ParentDirMissing verifies that hostFs.WriteFile creates parent dirs automatically. - -func TestHostFs_Write_ParentDirMissing(t *testing.T) { +// TestHostRW_Write_ParentDirMissing verifies that hostRW.Write creates parent dirs automatically. +func TestHostRW_Write_ParentDirMissing(t *testing.T) { tmpDir := t.TempDir() - target := filepath.Join(tmpDir, "a", "b", "c", "file.txt") err := (&hostFs{}).WriteFile(target, []byte("hello")) - assert.NoError(t, err) data, err := os.ReadFile(target) - assert.NoError(t, err) - assert.Equal(t, "hello", string(data)) } -// TestSandboxFs_Write_ParentDirMissing verifies that sandboxFs.WriteFile creates - +// TestRootRW_Write_ParentDirMissing verifies that rootRW.Write creates // nested parent directories automatically within the sandbox. - -func TestSandboxFs_Write_ParentDirMissing(t *testing.T) { +func TestRootRW_Write_ParentDirMissing(t *testing.T) { workspace := t.TempDir() relPath := "x/y/z/file.txt" - err := (&sandboxFs{workspace: workspace}).WriteFile(relPath, []byte("nested")) - assert.NoError(t, err) data, err := os.ReadFile(filepath.Join(workspace, relPath)) - assert.NoError(t, err) - assert.Equal(t, "nested", string(data)) } -// TestHostFs_Write verifies the hostFs.WriteFile helper function - -func TestHostFs_Write(t *testing.T) { +// TestHostRW_Write verifies the hostRW.Write helper function +func TestHostRW_Write(t *testing.T) { tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "atomic_test.txt") - testData := []byte("atomic test content") err := (&hostFs{}).WriteFile(testFile, testData) - assert.NoError(t, err) content, err := os.ReadFile(testFile) - assert.NoError(t, err) - assert.Equal(t, testData, content) // Verify it overwrites correctly - newData := []byte("new atomic content") - err = (&hostFs{}).WriteFile(testFile, newData) - assert.NoError(t, err) content, err = os.ReadFile(testFile) - assert.NoError(t, err) - assert.Equal(t, newData, content) } -// TestSandboxFs_Write verifies the sandboxFs.WriteFile helper function - -func TestSandboxFs_Write(t *testing.T) { +// TestRootRW_Write verifies the rootRW.Write helper function +func TestRootRW_Write(t *testing.T) { //nolint:dupl tmpDir := t.TempDir() relPath := "atomic_root_test.txt" - testData := []byte("atomic root test content") erw := &sandboxFs{workspace: tmpDir} - err := erw.WriteFile(relPath, testData) - assert.NoError(t, err) root, err := os.OpenRoot(tmpDir) - assert.NoError(t, err) - defer root.Close() f, err := root.Open(relPath) - assert.NoError(t, err) - defer f.Close() content, err := io.ReadAll(f) - assert.NoError(t, err) - assert.Equal(t, testData, content) // Verify it overwrites correctly - newData := []byte("new root atomic content") - err = erw.WriteFile(relPath, newData) - assert.NoError(t, err) f2, err := root.Open(relPath) - assert.NoError(t, err) - defer f2.Close() content, err = io.ReadAll(f2) - assert.NoError(t, err) - assert.Equal(t, newData, content) } -// TestValidatePath_OutsideWorkspace_IncludesPath verifies that the access - -// denied error includes the workspace path so the caller knows the boundary. - -func TestValidatePath_OutsideWorkspace_IncludesPath(t *testing.T) { +// TestWhitelistFs_AllowsMatchingPaths verifies that whitelistFs allows access to +// paths matching the whitelist patterns while blocking non-matching paths. +func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) { workspace := t.TempDir() + outsideDir := t.TempDir() + outsideFile := filepath.Join(outsideDir, "allowed.txt") + os.WriteFile(outsideFile, []byte("outside content"), 0o644) - outsidePath := filepath.Join(t.TempDir(), "secret.txt") + // Pattern allows access to the outsideDir. + patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(outsideDir))} - _, err := validatePath(outsidePath, workspace, true) + tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) - assert.Error(t, err) + // Read from whitelisted path should succeed. + result := tool.Execute(context.Background(), map[string]any{"path": outsideFile}) + if result.IsError { + t.Errorf("expected whitelisted path to be readable, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "outside content") { + t.Errorf("expected file content, got: %s", result.ForLLM) + } - assert.Contains(t, err.Error(), "access denied") + // Read from non-whitelisted path outside workspace should fail. + otherDir := t.TempDir() + otherFile := filepath.Join(otherDir, "blocked.txt") + os.WriteFile(otherFile, []byte("blocked"), 0o644) - assert.Contains(t, err.Error(), workspace) + result = tool.Execute(context.Background(), map[string]any{"path": otherFile}) + if !result.IsError { + t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM) + } +} + +// TestReadFileTool_ChunkedReading verifies the pagination logic of the tool +// by reading a file in multiple chunks using 'offset' and 'length'. +func TestReadFileTool_ChunkedReading(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "pagination_test.txt") + + // Create a test file with exactly 26 bytes of content + fullContent := "abcdefghijklmnopqrstuvwxyz" + err := os.WriteFile(testFile, []byte(fullContent), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileTool(tmpDir, false, MaxReadFileSize) + ctx := context.Background() + + // --- Step 1: Read the first chunk (10 bytes) --- + args1 := map[string]any{ + "path": testFile, + "offset": 0, + "length": 10, + } + result1 := tool.Execute(ctx, args1) + + if result1.IsError { + t.Fatalf("Chunk 1 failed: %s", result1.ForLLM) + } + + // Expect the first 10 characters + if !strings.Contains(result1.ForLLM, "abcdefghij") { + t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM) + } + // Expect the header to indicate the file is truncated + if !strings.Contains(result1.ForLLM, "[TRUNCATED") { + t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM) + } + // Expect the header to suggest the next offset (10) + if !strings.Contains(result1.ForLLM, "offset=10") { + t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM) + } + + // Step 2: Read the second chunk (10 bytes) --- + args2 := map[string]any{ + "path": testFile, + "offset": 10, + "length": 10, + } + result2 := tool.Execute(ctx, args2) + + if result2.IsError { + t.Fatalf("Chunk 2 failed: %s", result2.ForLLM) + } + + // Expect the next 10 characters + if !strings.Contains(result2.ForLLM, "klmnopqrst") { + t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM) + } + // Expect the header to suggest the next offset (20) + if !strings.Contains(result2.ForLLM, "offset=20") { + t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM) + } + + // Step 3: Read the final chunk (remaining 6 bytes) --- + // We ask for 10 bytes, but only 6 are left in the file + args3 := map[string]any{ + "path": testFile, + "offset": 20, + "length": 10, + } + result3 := tool.Execute(ctx, args3) + + if result3.IsError { + t.Fatalf("Chunk 3 failed: %s", result3.ForLLM) + } + + // Expect the last 6 characters + if !strings.Contains(result3.ForLLM, "uvwxyz") { + t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM) + } + // Expect the header to indicate the end of the file + if !strings.Contains(result3.ForLLM, "[END OF FILE") { + t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM) + } + + // Ensure no TRUNCATED message is present in the final chunk + if strings.Contains(result3.ForLLM, "[TRUNCATED") { + t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM) + } +} + +// TestReadFileTool_OffsetBeyondEOF checks the behavior when requesting +// An offset that exceeds the total file size. +func TestReadFileTool_OffsetBeyondEOF(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "short.txt") + + // create a file of only 5 bytes + err := os.WriteFile(testFile, []byte("12345"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileTool(tmpDir, false, MaxReadFileSize) + ctx := context.Background() + + args := map[string]any{ + "path": testFile, + "offset": int64(100), // Offset beyond the end of the file + } + + result := tool.Execute(ctx, args) + + // It should not be classified as a tool execution error + if result.IsError { + t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM) + } + + // Must return EXACTLY the string provided in the code + expectedMsg := "[END OF FILE - no content at this offset]" + if result.ForLLM != expectedMsg { + t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM) + } } diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go index d3d7ebe10..779b1d5a7 100644 --- a/pkg/tools/i2c.go +++ b/pkg/tools/i2c.go @@ -10,7 +10,6 @@ import ( ) // I2CTool provides I2C bus interaction for reading sensors and controlling peripherals. - type I2CTool struct{} func NewI2CTool() *I2CTool { @@ -28,55 +27,38 @@ func (t *I2CTool) Description() string { func (t *I2CTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "action": map[string]any{ - "type": "string", - - "enum": []string{"detect", "scan", "read", "write"}, - + "type": "string", + "enum": []string{"detect", "scan", "read", "write"}, "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)", }, - "bus": map[string]any{ - "type": "string", - + "type": "string", "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.", }, - "address": map[string]any{ - "type": "integer", - + "type": "integer", "description": "7-bit I2C device address (0x03-0x77). Required for read/write.", }, - "register": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Register address to read from or write to. If set, sends register byte before read/write.", }, - "data": map[string]any{ - "type": "array", - - "items": map[string]any{"type": "integer"}, - + "type": "array", + "items": map[string]any{"type": "integer"}, "description": "Bytes to write (0-255 each). Required for write action.", }, - "length": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Number of bytes to read (1-256). Default: 1. Used with read action.", }, - "confirm": map[string]any{ - "type": "boolean", - + "type": "boolean", "description": "Must be true for write operations. Safety guard to prevent accidental writes.", }, }, - "required": []string{"action"}, } } @@ -87,36 +69,25 @@ func (t *I2CTool) Execute(ctx context.Context, args map[string]any) *ToolResult } action, ok := args["action"].(string) - if !ok { return ErrorResult("action is required") } switch action { case "detect": - return t.detect() - case "scan": - return t.scan(args) - case "read": - return t.readDevice(args) - case "write": - return t.writeDevice(args) - default: - return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action)) } } // detect lists available I2C buses by globbing /dev/i2c-* - func (t *I2CTool) detect() *ToolResult { matches, err := filepath.Glob("/dev/i2c-*") if err != nil { @@ -131,14 +102,11 @@ func (t *I2CTool) detect() *ToolResult { type busInfo struct { Path string `json:"path"` - - Bus string `json:"bus"` + Bus string `json:"bus"` } buses := make([]busInfo, 0, len(matches)) - re := regexp.MustCompile(`/dev/i2c-(\d+)`) - for _, m := range matches { if sub := re.FindStringSubmatch(m); sub != nil { buses = append(buses, busInfo{Path: m, Bus: sub[1]}) @@ -146,62 +114,44 @@ func (t *I2CTool) detect() *ToolResult { } result, _ := json.MarshalIndent(buses, "", " ") - return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result))) } // Helper functions for I2C operations (used by platform-specific implementations) // isValidBusID checks that a bus identifier is a simple number (prevents path injection) - // - //nolint:unused // Used by i2c_linux.go - func isValidBusID(id string) bool { matched, _ := regexp.MatchString(`^\d+$`, id) - return matched } // parseI2CAddress extracts and validates an I2C address from args - // - //nolint:unused // Used by i2c_linux.go - func parseI2CAddress(args map[string]any) (int, *ToolResult) { addrFloat, ok := args["address"].(float64) - if !ok { return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)") } - addr := int(addrFloat) - if addr < 0x03 || addr > 0x77 { return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)") } - return addr, nil } // parseI2CBus extracts and validates an I2C bus from args - // - //nolint:unused // Used by i2c_linux.go - func parseI2CBus(args map[string]any) (string, *ToolResult) { bus, ok := args["bus"].(string) - if !ok || bus == "" { return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)") } - if !isValidBusID(bus) { return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")") } - return bus, nil } diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go index aa0c06eb7..4eaaf8f09 100644 --- a/pkg/tools/i2c_linux.go +++ b/pkg/tools/i2c_linux.go @@ -97,12 +97,10 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { hasQuick := funcs&i2cFuncSmbusQuick != 0 hasReadByte := funcs&i2cFuncSmbusReadByte != 0 + if !hasQuick && !hasReadByte { return ErrorResult( - fmt.Sprintf( - "I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", - devPath, - ), + fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath), ) } @@ -112,7 +110,6 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { } var found []deviceEntry - // Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07 for addr := 0x08; addr <= 0x77; addr++ { // Set slave address — EBUSY means a kernel driver owns this address @@ -126,6 +123,7 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { } continue } + if smbusProbe(fd, addr, hasQuick) { found = append(found, deviceEntry{ Address: fmt.Sprintf("0x%02x", addr), @@ -142,11 +140,10 @@ func (t *I2CTool) scan(args map[string]any) *ToolResult { "devices": found, "count": len(found), }, "", " ") - return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result))) } -// readDevice reads bytes from an I2C device, optionally at a specific register. +// readDevice reads bytes from an I2C device, optionally at a specific register func (t *I2CTool) readDevice(args map[string]any) *ToolResult { bus, errResult := parseI2CBus(args) if errResult != nil { @@ -213,18 +210,15 @@ func (t *I2CTool) readDevice(args map[string]any) *ToolResult { "hex": hexBytes, "length": n, }, "", " ") - return SilentResult(string(result)) } -// writeDevice writes bytes to an I2C device, optionally at a specific register. +// writeDevice writes bytes to an I2C device, optionally at a specific register func (t *I2CTool) writeDevice(args map[string]any) *ToolResult { confirm, _ := args["confirm"].(bool) if !confirm { return ErrorResult( - "write operations require confirm: true." + - " Please confirm with the user before writing to I2C devices," + - " as incorrect writes can misconfigure hardware.", + "write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.", ) } diff --git a/pkg/tools/message.go b/pkg/tools/message.go index 7efebd5ad..438ceeddd 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -3,18 +3,14 @@ package tools import ( "context" "fmt" + "sync/atomic" ) type SendCallback func(channel, chatID, content string) error type MessageTool struct { sendCallback SendCallback - - defaultChannel string - - defaultChatID string - - sentInRound bool // Tracks whether a message was sent in the current processing round + sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round } func NewMessageTool() *MessageTool { @@ -32,43 +28,33 @@ func (t *MessageTool) Description() string { func (t *MessageTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "content": map[string]any{ - "type": "string", - + "type": "string", "description": "The message content to send", }, - "channel": map[string]any{ - "type": "string", - + "type": "string", "description": "Optional: target channel (telegram, whatsapp, etc.)", }, - "chat_id": map[string]any{ - "type": "string", - + "type": "string", "description": "Optional: target chat/user ID", }, }, - "required": []string{"content"}, } } -func (t *MessageTool) SetContext(channel, chatID string) { - t.defaultChannel = channel - - t.defaultChatID = chatID - - t.sentInRound = false // Reset send tracking for new processing round +// ResetSentInRound resets the per-round send tracker. +// Called by the agent loop at the start of each inbound message processing round. +func (t *MessageTool) ResetSentInRound() { + t.sentInRound.Store(false) } // HasSentInRound returns true if the message tool sent a message during the current round. - func (t *MessageTool) HasSentInRound() bool { - return t.sentInRound + return t.sentInRound.Load() } func (t *MessageTool) SetSendCallback(callback SendCallback) { @@ -77,21 +63,18 @@ func (t *MessageTool) SetSendCallback(callback SendCallback) { func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { content, ok := args["content"].(string) - if !ok { return &ToolResult{ForLLM: "content is required", IsError: true} } channel, _ := args["channel"].(string) - chatID, _ := args["chat_id"].(string) if channel == "" { - channel = t.defaultChannel + channel = ToolChannel(ctx) } - if chatID == "" { - chatID = t.defaultChatID + chatID = ToolChatID(ctx) } if channel == "" || chatID == "" { @@ -104,21 +87,16 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes if err := t.sendCallback(channel, chatID, content); err != nil { return &ToolResult{ - ForLLM: fmt.Sprintf("sending message: %v", err), - + ForLLM: fmt.Sprintf("sending message: %v", err), IsError: true, - - Err: err, + Err: err, } } - t.sentInRound = true - + t.sentInRound.Store(true) // Silent: user already received the message directly - return &ToolResult{ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), - Silent: true, } } diff --git a/pkg/tools/message_test.go b/pkg/tools/message_test.go index f49beab76..05630972e 100644 --- a/pkg/tools/message_test.go +++ b/pkg/tools/message_test.go @@ -9,22 +9,15 @@ import ( func TestMessageTool_Execute_Success(t *testing.T) { tool := NewMessageTool() - tool.SetContext("test-channel", "test-chat-id") - var sentChannel, sentChatID, sentContent string - tool.SetSendCallback(func(channel, chatID, content string) error { sentChannel = channel - sentChatID = chatID - sentContent = content - return nil }) - ctx := context.Background() - + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{ "content": "Hello, world!", } @@ -32,41 +25,33 @@ func TestMessageTool_Execute_Success(t *testing.T) { result := tool.Execute(ctx, args) // Verify message was sent with correct parameters - if sentChannel != "test-channel" { t.Errorf("Expected channel 'test-channel', got '%s'", sentChannel) } - if sentChatID != "test-chat-id" { t.Errorf("Expected chatID 'test-chat-id', got '%s'", sentChatID) } - if sentContent != "Hello, world!" { t.Errorf("Expected content 'Hello, world!', got '%s'", sentContent) } // Verify ToolResult meets US-011 criteria: - // - Send success returns SilentResult (Silent=true) - if !result.Silent { t.Error("Expected Silent=true for successful send") } // - ForLLM contains send status description - if result.ForLLM != "Message sent to test-channel:test-chat-id" { t.Errorf("Expected ForLLM 'Message sent to test-channel:test-chat-id', got '%s'", result.ForLLM) } // - ForUser is empty (user already received message directly) - if result.ForUser != "" { t.Errorf("Expected ForUser to be empty, got '%s'", result.ForUser) } // - IsError should be false - if result.IsError { t.Error("Expected IsError=false for successful send") } @@ -75,36 +60,26 @@ func TestMessageTool_Execute_Success(t *testing.T) { func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { tool := NewMessageTool() - tool.SetContext("default-channel", "default-chat-id") - var sentChannel, sentChatID string - tool.SetSendCallback(func(channel, chatID, content string) error { sentChannel = channel - sentChatID = chatID - return nil }) - ctx := context.Background() - + ctx := WithToolContext(context.Background(), "default-channel", "default-chat-id") args := map[string]any{ "content": "Test message", - "channel": "custom-channel", - "chat_id": "custom-chat-id", } result := tool.Execute(ctx, args) // Verify custom channel/chatID were used instead of defaults - if sentChannel != "custom-channel" { t.Errorf("Expected channel 'custom-channel', got '%s'", sentChannel) } - if sentChatID != "custom-chat-id" { t.Errorf("Expected chatID 'custom-chat-id', got '%s'", sentChatID) } @@ -112,7 +87,6 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { if !result.Silent { t.Error("Expected Silent=true") } - if result.ForLLM != "Message sent to custom-channel:custom-chat-id" { t.Errorf("Expected ForLLM 'Message sent to custom-channel:custom-chat-id', got '%s'", result.ForLLM) } @@ -121,16 +95,12 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) { func TestMessageTool_Execute_SendFailure(t *testing.T) { tool := NewMessageTool() - tool.SetContext("test-channel", "test-chat-id") - sendErr := errors.New("network error") - tool.SetSendCallback(func(channel, chatID, content string) error { return sendErr }) - ctx := context.Background() - + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{ "content": "Test message", } @@ -138,27 +108,21 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) { result := tool.Execute(ctx, args) // Verify ToolResult for send failure: - // - Send failure returns ErrorResult (IsError=true) - if !result.IsError { t.Error("Expected IsError=true for failed send") } // - ForLLM contains error description - expectedErrMsg := "sending message: network error" - if result.ForLLM != expectedErrMsg { t.Errorf("Expected ForLLM '%s', got '%s'", expectedErrMsg, result.ForLLM) } // - Err field should contain original error - if result.Err == nil { t.Error("Expected Err to be set") } - if result.Err != sendErr { t.Errorf("Expected Err to be sendErr, got %v", result.Err) } @@ -167,20 +131,15 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) { func TestMessageTool_Execute_MissingContent(t *testing.T) { tool := NewMessageTool() - tool.SetContext("test-channel", "test-chat-id") - - ctx := context.Background() - + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{} // content missing result := tool.Execute(ctx, args) // Verify error result for missing content - if !result.IsError { t.Error("Expected IsError=true for missing content") } - if result.ForLLM != "content is required" { t.Errorf("Expected ForLLM 'content is required', got '%s'", result.ForLLM) } @@ -188,15 +147,13 @@ func TestMessageTool_Execute_MissingContent(t *testing.T) { func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { tool := NewMessageTool() - - // No SetContext called, so defaultChannel and defaultChatID are empty + // No WithToolContext — channel/chatID are empty tool.SetSendCallback(func(channel, chatID, content string) error { return nil }) ctx := context.Background() - args := map[string]any{ "content": "Test message", } @@ -204,11 +161,9 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { result := tool.Execute(ctx, args) // Verify error when no target channel specified - if !result.IsError { t.Error("Expected IsError=true when no target channel") } - if result.ForLLM != "No target channel/chat specified" { t.Errorf("Expected ForLLM 'No target channel/chat specified', got '%s'", result.ForLLM) } @@ -216,13 +171,9 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) { func TestMessageTool_Execute_NotConfigured(t *testing.T) { tool := NewMessageTool() - - tool.SetContext("test-channel", "test-chat-id") - // No SetSendCallback called - ctx := context.Background() - + ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id") args := map[string]any{ "content": "Test message", } @@ -230,11 +181,9 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) { result := tool.Execute(ctx, args) // Verify error when send callback not configured - if !result.IsError { t.Error("Expected IsError=true when send callback not configured") } - if result.ForLLM != "Message sending not configured" { t.Errorf("Expected ForLLM 'Message sending not configured', got '%s'", result.ForLLM) } @@ -242,7 +191,6 @@ func TestMessageTool_Execute_NotConfigured(t *testing.T) { func TestMessageTool_Name(t *testing.T) { tool := NewMessageTool() - if tool.Name() != "message" { t.Errorf("Expected name 'message', got '%s'", tool.Name()) } @@ -250,9 +198,7 @@ func TestMessageTool_Name(t *testing.T) { func TestMessageTool_Description(t *testing.T) { tool := NewMessageTool() - desc := tool.Description() - if desc == "" { t.Error("Description should not be empty") } @@ -260,63 +206,48 @@ func TestMessageTool_Description(t *testing.T) { func TestMessageTool_Parameters(t *testing.T) { tool := NewMessageTool() - params := tool.Parameters() // Verify parameters structure - typ, ok := params["type"].(string) - if !ok || typ != "object" { t.Error("Expected type 'object'") } props, ok := params["properties"].(map[string]any) - if !ok { t.Fatal("Expected properties to be a map") } // Check required properties - required, ok := params["required"].([]string) - if !ok || len(required) != 1 || required[0] != "content" { t.Error("Expected 'content' to be required") } // Check content property - contentProp, ok := props["content"].(map[string]any) - if !ok { t.Error("Expected 'content' property") } - if contentProp["type"] != "string" { t.Error("Expected content type to be 'string'") } // Check channel property (optional) - channelProp, ok := props["channel"].(map[string]any) - if !ok { t.Error("Expected 'channel' property") } - if channelProp["type"] != "string" { t.Error("Expected channel type to be 'string'") } // Check chat_id property (optional) - chatIDProp, ok := props["chat_id"].(map[string]any) - if !ok { t.Error("Expected 'chat_id' property") } - if chatIDProp["type"] != "string" { t.Error("Expected chat_id type to be 'string'") } diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 57e5f3818..5b52e07a1 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -7,72 +7,149 @@ import ( "sort" "strings" "sync" + "sync/atomic" "time" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" ) -// NormalizeToolName keeps only lowercase ASCII letters. - -// "read_file" → "readfile", "ReadFile" → "readfile", "read-file" → "readfile". - -func NormalizeToolName(s string) string { - var b strings.Builder - - for _, r := range s { - if r >= 'A' && r <= 'Z' { - b.WriteRune(r + 32) - } else if r >= 'a' && r <= 'z' { - b.WriteRune(r) - } - } - - return b.String() +type ToolEntry struct { + Tool Tool + IsCore bool + TTL int } type ToolRegistry struct { - tools map[string]Tool - - mu sync.RWMutex + tools map[string]*ToolEntry + mu sync.RWMutex + version atomic.Uint64 // incremented on Register/RegisterHidden for cache invalidation } func NewToolRegistry() *ToolRegistry { return &ToolRegistry{ - tools: make(map[string]Tool), + tools: make(map[string]*ToolEntry), } } func (r *ToolRegistry) Register(tool Tool) { r.mu.Lock() - defer r.mu.Unlock() + name := tool.Name() + if _, exists := r.tools[name]; exists { + logger.WarnCF("tools", "Tool registration overwrites existing tool", + map[string]any{"name": name}) + } + r.tools[name] = &ToolEntry{ + Tool: tool, + IsCore: true, + TTL: 0, // Core tools do not use TTL + } + r.version.Add(1) + logger.DebugCF("tools", "Registered core tool", map[string]any{"name": name}) +} - r.tools[tool.Name()] = tool +// RegisterHidden saves hidden tools (visible only via TTL) +func (r *ToolRegistry) RegisterHidden(tool Tool) { + r.mu.Lock() + defer r.mu.Unlock() + name := tool.Name() + if _, exists := r.tools[name]; exists { + logger.WarnCF("tools", "Hidden tool registration overwrites existing tool", + map[string]any{"name": name}) + } + r.tools[name] = &ToolEntry{ + Tool: tool, + IsCore: false, + TTL: 0, + } + r.version.Add(1) + logger.DebugCF("tools", "Registered hidden tool", map[string]any{"name": name}) +} + +// PromoteTools atomically sets the TTL for multiple non-core tools. +// This prevents a concurrent TickTTL from decrementing between promotions. +func (r *ToolRegistry) PromoteTools(names []string, ttl int) { + r.mu.Lock() + defer r.mu.Unlock() + promoted := 0 + for _, name := range names { + if entry, exists := r.tools[name]; exists { + if !entry.IsCore { + entry.TTL = ttl + promoted++ + } + } + } + logger.DebugCF( + "tools", + "PromoteTools completed", + map[string]any{"requested": len(names), "promoted": promoted, "ttl": ttl}, + ) +} + +// TickTTL decreases TTL only for non-core tools +func (r *ToolRegistry) TickTTL() { + r.mu.Lock() + defer r.mu.Unlock() + for _, entry := range r.tools { + if !entry.IsCore && entry.TTL > 0 { + entry.TTL-- + } + } +} + +// Version returns the current registry version (atomically). +func (r *ToolRegistry) Version() uint64 { + return r.version.Load() +} + +// HiddenToolSnapshot holds a consistent snapshot of hidden tools and the +// registry version at which it was taken. Used by BM25SearchTool cache. +type HiddenToolSnapshot struct { + Docs []HiddenToolDoc + Version uint64 +} + +// HiddenToolDoc is a lightweight representation of a hidden tool for search indexing. +type HiddenToolDoc struct { + Name string + Description string +} + +// SnapshotHiddenTools returns all non-core tools and the current registry +// version under a single read-lock, guaranteeing consistency between the +// two values. +func (r *ToolRegistry) SnapshotHiddenTools() HiddenToolSnapshot { + r.mu.RLock() + defer r.mu.RUnlock() + docs := make([]HiddenToolDoc, 0, len(r.tools)) + for name, entry := range r.tools { + if !entry.IsCore { + docs = append(docs, HiddenToolDoc{ + Name: name, + Description: entry.Tool.Description(), + }) + } + } + return HiddenToolSnapshot{ + Docs: docs, + Version: r.version.Load(), + } } func (r *ToolRegistry) Get(name string) (Tool, bool) { r.mu.RLock() - defer r.mu.RUnlock() - - // Exact match first - - if tool, ok := r.tools[name]; ok { - return tool, true + entry, ok := r.tools[name] + if !ok { + return nil, false } - - // Fuzzy fallback: normalize and compare (handles "readfile" → "read_file" etc.) - - norm := NormalizeToolName(name) - - for _, tool := range r.tools { - if NormalizeToolName(tool.Name()) == norm { - return tool, true - } + // Hidden tools with expired TTL are not callable. + if !entry.IsCore && entry.TTL <= 0 { + return nil, false } - - return nil, false + return entry.Tool, true } func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]any) *ToolResult { @@ -80,99 +157,69 @@ func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string } // ExecuteWithContext executes a tool with channel/chatID context and optional async callback. - -// If the tool implements AsyncTool and a non-nil callback is provided, - -// the callback will be set on the tool before execution. - +// If the tool implements AsyncExecutor and a non-nil callback is provided, +// ExecuteAsync is called instead of Execute — the callback is a parameter, +// never stored as mutable state on the tool. func (r *ToolRegistry) ExecuteWithContext( ctx context.Context, - name string, - args map[string]any, - channel, chatID string, - asyncCallback AsyncCallback, ) *ToolResult { logger.InfoCF("tool", "Tool execution started", - map[string]any{ "tool": name, - "args": args, }) tool, ok := r.Get(name) - if !ok { - available := strings.Join(r.List(), ", ") - logger.ErrorCF("tool", "Tool not found", - map[string]any{ "tool": name, }) - - return ErrorResult(fmt.Sprintf( - - "tool %q not found. Available tools: %s", name, available, - )).WithError(fmt.Errorf("tool not found")) + return ErrorResult(fmt.Sprintf("tool %q not found", name)).WithError(fmt.Errorf("tool not found")) } - // If tool implements ContextualTool, set context - - if contextualTool, ok := tool.(ContextualTool); ok && channel != "" && chatID != "" { - contextualTool.SetContext(channel, chatID) - } - - // If tool implements AsyncTool and callback is provided, set callback - - if asyncTool, ok := tool.(AsyncTool); ok && asyncCallback != nil { - asyncTool.SetCallback(asyncCallback) - - logger.DebugCF("tool", "Async callback injected", - - map[string]any{ - "tool": name, - }) - } + // Inject channel/chatID into ctx so tools read them via ToolChannel(ctx)/ToolChatID(ctx). + // Always inject — tools validate what they require. + ctx = WithToolContext(ctx, channel, chatID) + // If tool implements AsyncExecutor and callback is provided, use ExecuteAsync. + // The callback is a call parameter, not mutable state on the tool instance. + var result *ToolResult start := time.Now() - - result := tool.Execute(ctx, args) - + if asyncExec, ok := tool.(AsyncExecutor); ok && asyncCallback != nil { + logger.DebugCF("tool", "Executing async tool via ExecuteAsync", + map[string]any{ + "tool": name, + }) + result = asyncExec.ExecuteAsync(ctx, args, asyncCallback) + } else { + result = tool.Execute(ctx, args) + } duration := time.Since(start) // Log based on result type - if result.IsError { logger.ErrorCF("tool", "Tool execution failed", - map[string]any{ - "tool": name, - + "tool": name, "duration": duration.Milliseconds(), - - "error": result.ForLLM, + "error": result.ForLLM, }) } else if result.Async { logger.InfoCF("tool", "Tool started (async)", - map[string]any{ - "tool": name, - + "tool": name, "duration": duration.Milliseconds(), }) } else { logger.InfoCF("tool", "Tool execution completed", - map[string]any{ - "tool": name, - - "duration_ms": duration.Milliseconds(), - + "tool": name, + "duration_ms": duration.Milliseconds(), "result_length": len(result.ForLLM), }) } @@ -181,211 +228,124 @@ func (r *ToolRegistry) ExecuteWithContext( } // sortedToolNames returns tool names in sorted order for deterministic iteration. - // This is critical for KV cache stability: non-deterministic map iteration would - // produce different system prompts and tool definitions on each call, invalidating - // the LLM's prefix cache even when no tools have changed. - func (r *ToolRegistry) sortedToolNames() []string { names := make([]string, 0, len(r.tools)) - for name := range r.tools { names = append(names, name) } - sort.Strings(names) - return names } func (r *ToolRegistry) GetDefinitions() []map[string]any { r.mu.RLock() - defer r.mu.RUnlock() sorted := r.sortedToolNames() - definitions := make([]map[string]any, 0, len(sorted)) - for _, name := range sorted { - definitions = append(definitions, ToolToSchema(r.tools[name])) - } + entry := r.tools[name] + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + definitions = append(definitions, ToolToSchema(r.tools[name].Tool)) + } return definitions } // ToProviderDefs converts tool definitions to provider-compatible format. - // This is the format expected by LLM provider APIs. - func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { r.mu.RLock() - defer r.mu.RUnlock() sorted := r.sortedToolNames() - definitions := make([]providers.ToolDefinition, 0, len(sorted)) - for _, name := range sorted { - tool := r.tools[name] + entry := r.tools[name] - schema := ToolToSchema(tool) + if !entry.IsCore && entry.TTL <= 0 { + continue + } + + schema := ToolToSchema(entry.Tool) // Safely extract nested values with type checks - fn, ok := schema["function"].(map[string]any) - if !ok { continue } name, _ := fn["name"].(string) - desc, _ := fn["description"].(string) - params, _ := fn["parameters"].(map[string]any) - paramsRaw := json.RawMessage(`{}`) - - if len(params) > 0 { - if payload, err := json.Marshal(params); err == nil { - paramsRaw = json.RawMessage(payload) - } + var paramsRaw json.RawMessage + if params != nil { + paramsRaw, _ = json.Marshal(params) } definitions = append(definitions, providers.ToolDefinition{ Type: "function", - Function: providers.ToolFunctionDefinition{ - Name: name, - + Name: name, Description: desc, - - Parameters: paramsRaw, + Parameters: paramsRaw, }, }) } - return definitions } -// List returns a list of all registered tool names. +// NormalizeToolName lowercases the name and strips underscores/hyphens so that +// "read_file", "ReadFile", and "read-file" all map to "readfile". +func NormalizeToolName(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range strings.ToLower(name) { + if r != '_' && r != '-' { + b.WriteRune(r) + } + } + return b.String() +} +// List returns a list of all registered tool names. func (r *ToolRegistry) List() []string { r.mu.RLock() - defer r.mu.RUnlock() return r.sortedToolNames() } // Count returns the number of registered tools. - func (r *ToolRegistry) Count() int { r.mu.RLock() - defer r.mu.RUnlock() - return len(r.tools) } -// GetRuntimeStatus aggregates runtime status from all tools that implement StatusProvider. - -// Returns empty string if no tool has status to report. - -func (r *ToolRegistry) GetRuntimeStatus() string { - r.mu.RLock() - - defer r.mu.RUnlock() - - var parts []string - - for _, tool := range r.tools { - if sp, ok := tool.(StatusProvider); ok { - if s := sp.RuntimeStatus(); s != "" { - parts = append(parts, s) - } - } - } - - if len(parts) == 0 { - return "" - } - - return strings.Join(parts, "\n\n") -} - -// buildParamHint extracts parameter names from a JSON schema and returns - -// a hint string like "(task, label?, preset?)". Required params are bare, - -// optional params have a trailing "?". - -func buildParamHint(schema map[string]any) string { - props, _ := schema["properties"].(map[string]any) - - if len(props) == 0 { - return "" - } - - reqSlice, _ := schema["required"].([]string) - - reqSet := make(map[string]bool, len(reqSlice)) - - for _, r := range reqSlice { - reqSet[r] = true - } - - names := make([]string, 0, len(props)) - - for name := range props { - names = append(names, name) - } - - sort.Strings(names) - - parts := make([]string, 0, len(names)) - - // Required params first, then optional - - for _, name := range names { - if reqSet[name] { - parts = append(parts, name) - } - } - - for _, name := range names { - if !reqSet[name] { - parts = append(parts, name+"?") - } - } - - return "(" + strings.Join(parts, ", ") + ")" -} - // GetSummaries returns human-readable summaries of all registered tools. - -// Returns a slice of "- `name`(params) - description" strings. - +// Returns a slice of "name - description" strings. func (r *ToolRegistry) GetSummaries() []string { r.mu.RLock() - defer r.mu.RUnlock() sorted := r.sortedToolNames() - summaries := make([]string, 0, len(sorted)) - for _, name := range sorted { - tool := r.tools[name] + entry := r.tools[name] - hint := buildParamHint(tool.Parameters()) + if !entry.IsCore && entry.TTL <= 0 { + continue + } - summaries = append(summaries, fmt.Sprintf("- `%s`%s - %s", tool.Name(), hint, tool.Description())) + summaries = append(summaries, fmt.Sprintf("- `%s` - %s", entry.Tool.Name(), entry.Tool.Description())) } - return summaries } diff --git a/pkg/tools/registry_ext.go b/pkg/tools/registry_ext.go new file mode 100644 index 000000000..e2f88f171 --- /dev/null +++ b/pkg/tools/registry_ext.go @@ -0,0 +1,68 @@ +package tools + +import ( + "sort" + "strings" +) + +// --- Fork-only registry extensions --- + +// GetRuntimeStatus aggregates runtime status from all tools that implement StatusProvider. +// Returns empty string if no tool has status to report. +func (r *ToolRegistry) GetRuntimeStatus() string { + r.mu.RLock() + defer r.mu.RUnlock() + + var parts []string + for _, entry := range r.tools { + if sp, ok := entry.Tool.(StatusProvider); ok { + if s := sp.RuntimeStatus(); s != "" { + parts = append(parts, s) + } + } + } + + if len(parts) == 0 { + return "" + } + + return strings.Join(parts, "\n\n") +} + +// buildParamHint extracts parameter names from a JSON schema and returns +// a hint string like "(task, label?, preset?)". Required params are bare, +// optional params have a trailing "?". +func buildParamHint(schema map[string]any) string { + props, _ := schema["properties"].(map[string]any) + if len(props) == 0 { + return "" + } + + reqSlice, _ := schema["required"].([]string) + reqSet := make(map[string]bool, len(reqSlice)) + for _, r := range reqSlice { + reqSet[r] = true + } + + names := make([]string, 0, len(props)) + for name := range props { + names = append(names, name) + } + sort.Strings(names) + + parts := make([]string, 0, len(names)) + + // Required params first, then optional + for _, name := range names { + if reqSet[name] { + parts = append(parts, name) + } + } + for _, name := range names { + if !reqSet[name] { + parts = append(parts, name+"?") + } + } + + return "(" + strings.Join(parts, ", ") + ")" +} diff --git a/pkg/tools/registry_ext_test.go b/pkg/tools/registry_ext_test.go new file mode 100644 index 000000000..c1416b3c9 --- /dev/null +++ b/pkg/tools/registry_ext_test.go @@ -0,0 +1,207 @@ +package tools + +import ( + "context" + "strings" + "testing" +) + +func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) { + m.lastCB = cb +} + +func TestNormalizeToolName(t *testing.T) { + tests := []struct { + input, want string + }{ + {"read_file", "readfile"}, + + {"readfile", "readfile"}, + + {"ReadFile", "readfile"}, + + {"read-file", "readfile"}, + + {"edit_file", "editfile"}, + + {"web_search", "websearch"}, + + {"EXEC", "exec"}, + } + + for _, tt := range tests { + got := NormalizeToolName(tt.input) + + if got != tt.want { + t.Errorf("NormalizeToolName(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestToolRegistry_Get_ExactMatch(t *testing.T) { + r := NewToolRegistry() + + r.Register(newMockTool("read_file", "reads a file")) + r.Register(newMockTool("edit_file", "edits a file")) + r.Register(newMockTool("web_search", "searches the web")) + + // Exact matches should work + for _, name := range []string{"read_file", "edit_file", "web_search"} { + tool, ok := r.Get(name) + if !ok { + t.Errorf("Get(%q) not found", name) + continue + } + if tool.Name() != name { + t.Errorf("Get(%q).Name() = %q", name, tool.Name()) + } + } + + // Non-exact names should not match (Get is exact-only) + for _, name := range []string{"readfile", "ReadFile", "read-file"} { + if _, ok := r.Get(name); ok { + t.Errorf("Get(%q) should not match (exact lookup only)", name) + } + } +} + +func TestToolRegistry_ExecuteWithContext_InjectsContext(t *testing.T) { + r := NewToolRegistry() + + // Tool that reads context from ctx via ToolChannel/ToolChatID + contextCapture := newMockTool("ctx_tool", "needs context") + r.Register(contextCapture) + + result := r.ExecuteWithContext( + context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil, + ) + if result.IsError { + t.Errorf("unexpected error: %s", result.ForLLM) + } +} + +func TestBuildParamHint(t *testing.T) { + tests := []struct { + name string + + schema map[string]any + + want string + }{ + { + name: "required and optional", + + schema: map[string]any{ + "type": "object", + + "properties": map[string]any{ + "task": map[string]any{"type": "string"}, + + "label": map[string]any{"type": "string"}, + }, + + "required": []string{"task"}, + }, + + want: "(task, label?)", + }, + + { + name: "all required", + + schema: map[string]any{ + "type": "object", + + "properties": map[string]any{ + "command": map[string]any{"type": "string"}, + }, + + "required": []string{"command"}, + }, + + want: "(command)", + }, + + { + name: "no properties", + + schema: map[string]any{ + "type": "object", + }, + + want: "", + }, + + { + name: "empty schema", + + schema: map[string]any{}, + + want: "", + }, + + { + name: "nil schema", + + schema: nil, + + want: "", + }, + + { + name: "multiple optional sorted", + + schema: map[string]any{ + "type": "object", + + "properties": map[string]any{ + "task": map[string]any{"type": "string"}, + + "preset": map[string]any{"type": "string"}, + + "label": map[string]any{"type": "string"}, + + "agent_id": map[string]any{"type": "string"}, + }, + + "required": []string{"task"}, + }, + + want: "(task, agent_id?, label?, preset?)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := buildParamHint(tt.schema) + + if got != tt.want { + t.Errorf("buildParamHint() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestToolRegistry_GetSummaries_Format(t *testing.T) { + r := NewToolRegistry() + + r.Register(&mockRegistryTool{ + name: "spawn", + desc: "Spawn a subagent", + result: SilentResult("ok"), + }) + + summaries := r.GetSummaries() + + if len(summaries) != 1 { + t.Fatalf("expected 1 summary, got %d", len(summaries)) + } + + if !strings.Contains(summaries[0], "spawn") { + t.Errorf("expected tool name in summary, got %q", summaries[0]) + } + + if !strings.Contains(summaries[0], "Spawn a subagent") { + t.Errorf("expected description in summary, got %q", summaries[0]) + } +} diff --git a/pkg/tools/registry_test.go b/pkg/tools/registry_test.go index cff843c3e..b1b822243 100644 --- a/pkg/tools/registry_test.go +++ b/pkg/tools/registry_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "encoding/json" "strings" "sync" "testing" @@ -12,100 +13,57 @@ import ( // --- mock types --- type mockRegistryTool struct { - name string - - desc string - + name string + desc string params map[string]any - result *ToolResult } -func (m *mockRegistryTool) Name() string { return m.name } - -func (m *mockRegistryTool) Description() string { return m.desc } - +func (m *mockRegistryTool) Name() string { return m.name } +func (m *mockRegistryTool) Description() string { return m.desc } func (m *mockRegistryTool) Parameters() map[string]any { return m.params } - func (m *mockRegistryTool) Execute(_ context.Context, _ map[string]any) *ToolResult { return m.result } -type mockCtxTool struct { +type mockContextAwareTool struct { mockRegistryTool - - channel string - - chatID string + lastCtx context.Context } -func (m *mockCtxTool) SetContext(channel, chatID string) { - m.channel = channel - - m.chatID = chatID +func (m *mockContextAwareTool) Execute(ctx context.Context, _ map[string]any) *ToolResult { + m.lastCtx = ctx + return m.result } type mockAsyncRegistryTool struct { mockRegistryTool - - cb AsyncCallback + lastCB AsyncCallback } -func (m *mockAsyncRegistryTool) SetCallback(cb AsyncCallback) { - m.cb = cb +func (m *mockAsyncRegistryTool) ExecuteAsync(_ context.Context, args map[string]any, cb AsyncCallback) *ToolResult { + m.lastCB = cb + return m.result } // --- helpers --- func newMockTool(name, desc string) *mockRegistryTool { return &mockRegistryTool{ - name: name, - - desc: desc, - + name: name, + desc: desc, params: map[string]any{"type": "object"}, - result: SilentResult("ok"), } } // --- tests --- -func TestNormalizeToolName(t *testing.T) { - tests := []struct { - input, want string - }{ - {"read_file", "readfile"}, - - {"readfile", "readfile"}, - - {"ReadFile", "readfile"}, - - {"read-file", "readfile"}, - - {"edit_file", "editfile"}, - - {"web_search", "websearch"}, - - {"EXEC", "exec"}, - } - - for _, tt := range tests { - got := NormalizeToolName(tt.input) - - if got != tt.want { - t.Errorf("NormalizeToolName(%q) = %q, want %q", tt.input, got, tt.want) - } - } -} - func TestNewToolRegistry(t *testing.T) { r := NewToolRegistry() - if r.Count() != 0 { t.Errorf("expected empty registry, got count %d", r.Count()) } - if len(r.List()) != 0 { t.Errorf("expected empty list, got %v", r.List()) } @@ -113,17 +71,13 @@ func TestNewToolRegistry(t *testing.T) { func TestToolRegistry_RegisterAndGet(t *testing.T) { r := NewToolRegistry() - tool := newMockTool("echo", "echoes input") - r.Register(tool) got, ok := r.Get("echo") - if !ok { t.Fatal("expected to find registered tool") } - if got.Name() != "echo" { t.Errorf("expected name 'echo', got %q", got.Name()) } @@ -131,71 +85,21 @@ func TestToolRegistry_RegisterAndGet(t *testing.T) { func TestToolRegistry_Get_NotFound(t *testing.T) { r := NewToolRegistry() - _, ok := r.Get("nonexistent") - if ok { t.Error("expected ok=false for unregistered tool") } } -func TestToolRegistry_Get_FuzzyMatch(t *testing.T) { - r := NewToolRegistry() - - r.Register(newMockTool("read_file", "reads a file")) - - r.Register(newMockTool("edit_file", "edits a file")) - - r.Register(newMockTool("web_search", "searches the web")) - - tests := []struct { - query string - - wantName string - }{ - {"readfile", "read_file"}, - - {"ReadFile", "read_file"}, - - {"read-file", "read_file"}, - - {"editfile", "edit_file"}, - - {"EditFile", "edit_file"}, - - {"websearch", "web_search"}, - - {"WebSearch", "web_search"}, - } - - for _, tt := range tests { - tool, ok := r.Get(tt.query) - - if !ok { - t.Errorf("Get(%q) not found, want %q", tt.query, tt.wantName) - - continue - } - - if tool.Name() != tt.wantName { - t.Errorf("Get(%q).Name() = %q, want %q", tt.query, tool.Name(), tt.wantName) - } - } -} - func TestToolRegistry_RegisterOverwrite(t *testing.T) { r := NewToolRegistry() - r.Register(newMockTool("dup", "first")) - r.Register(newMockTool("dup", "second")) if r.Count() != 1 { t.Errorf("expected count 1 after overwrite, got %d", r.Count()) } - tool, _ := r.Get("dup") - if tool.Description() != "second" { t.Errorf("expected overwritten description 'second', got %q", tool.Description()) } @@ -203,23 +107,17 @@ func TestToolRegistry_RegisterOverwrite(t *testing.T) { func TestToolRegistry_Execute_Success(t *testing.T) { r := NewToolRegistry() - r.Register(&mockRegistryTool{ - name: "greet", - - desc: "says hello", - + name: "greet", + desc: "says hello", params: map[string]any{}, - result: SilentResult("hello"), }) result := r.Execute(context.Background(), "greet", nil) - if result.IsError { t.Errorf("expected success, got error: %s", result.ForLLM) } - if result.ForLLM != "hello" { t.Errorf("expected ForLLM 'hello', got %q", result.ForLLM) } @@ -227,85 +125,79 @@ func TestToolRegistry_Execute_Success(t *testing.T) { func TestToolRegistry_Execute_NotFound(t *testing.T) { r := NewToolRegistry() - result := r.Execute(context.Background(), "missing", nil) - if !result.IsError { t.Error("expected error for missing tool") } - if !strings.Contains(result.ForLLM, "not found") { t.Errorf("expected 'not found' in error, got %q", result.ForLLM) } - if result.Err == nil { t.Error("expected Err to be set via WithError") } } -func TestToolRegistry_ExecuteWithContext_ContextualTool(t *testing.T) { +func TestToolRegistry_ExecuteWithContext_InjectsToolContext(t *testing.T) { r := NewToolRegistry() - - ct := &mockCtxTool{ + ct := &mockContextAwareTool{ mockRegistryTool: *newMockTool("ctx_tool", "needs context"), } - r.Register(ct) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "telegram", "chat-42", nil) - if ct.channel != "telegram" { - t.Errorf("expected channel 'telegram', got %q", ct.channel) + if ct.lastCtx == nil { + t.Fatal("expected Execute to be called") } - - if ct.chatID != "chat-42" { - t.Errorf("expected chatID 'chat-42', got %q", ct.chatID) + if got := ToolChannel(ct.lastCtx); got != "telegram" { + t.Errorf("expected channel 'telegram', got %q", got) + } + if got := ToolChatID(ct.lastCtx); got != "chat-42" { + t.Errorf("expected chatID 'chat-42', got %q", got) } } -func TestToolRegistry_ExecuteWithContext_SkipsEmptyContext(t *testing.T) { +func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) { r := NewToolRegistry() - - ct := &mockCtxTool{ + ct := &mockContextAwareTool{ mockRegistryTool: *newMockTool("ctx_tool", "needs context"), } - r.Register(ct) r.ExecuteWithContext(context.Background(), "ctx_tool", nil, "", "", nil) - if ct.channel != "" || ct.chatID != "" { - t.Error("SetContext should not be called with empty channel/chatID") + if ct.lastCtx == nil { + t.Fatal("expected Execute to be called") + } + // Empty values are still injected; tools decide what to do with them. + if got := ToolChannel(ct.lastCtx); got != "" { + t.Errorf("expected empty channel, got %q", got) + } + if got := ToolChatID(ct.lastCtx); got != "" { + t.Errorf("expected empty chatID, got %q", got) } } func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) { r := NewToolRegistry() - at := &mockAsyncRegistryTool{ mockRegistryTool: *newMockTool("async_tool", "async work"), } - at.result = AsyncResult("started") - r.Register(at) called := false - cb := func(_ context.Context, _ *ToolResult) { called = true } result := r.ExecuteWithContext(context.Background(), "async_tool", nil, "", "", cb) - - if at.cb == nil { - t.Error("expected SetCallback to have been called") + if at.lastCB == nil { + t.Error("expected ExecuteAsync to have received a callback") } - if !result.Async { t.Error("expected async result") } - at.cb(context.Background(), SilentResult("done")) - + at.lastCB(context.Background(), SilentResult("done")) if !called { t.Error("expected callback to be invoked") } @@ -313,29 +205,22 @@ func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) { func TestToolRegistry_GetDefinitions(t *testing.T) { r := NewToolRegistry() - r.Register(newMockTool("alpha", "tool A")) defs := r.GetDefinitions() - if len(defs) != 1 { t.Fatalf("expected 1 definition, got %d", len(defs)) } - if defs[0]["type"] != "function" { t.Errorf("expected type 'function', got %v", defs[0]["type"]) } - fn, ok := defs[0]["function"].(map[string]any) - if !ok { t.Fatal("expected 'function' key to be a map") } - if fn["name"] != "alpha" { t.Errorf("expected name 'alpha', got %v", fn["name"]) } - if fn["description"] != "tool A" { t.Errorf("expected description 'tool A', got %v", fn["description"]) } @@ -343,47 +228,35 @@ func TestToolRegistry_GetDefinitions(t *testing.T) { func TestToolRegistry_ToProviderDefs(t *testing.T) { r := NewToolRegistry() - params := map[string]any{"type": "object", "properties": map[string]any{}} - r.Register(&mockRegistryTool{ - name: "beta", - - desc: "tool B", - + name: "beta", + desc: "tool B", params: params, - result: SilentResult("ok"), }) defs := r.ToProviderDefs() - if len(defs) != 1 { t.Fatalf("expected 1 provider def, got %d", len(defs)) } + paramsRaw, _ := json.Marshal(params) want := providers.ToolDefinition{ Type: "function", - Function: providers.ToolFunctionDefinition{ - Name: "beta", - + Name: "beta", Description: "tool B", - - Parameters: providers.MustMarshalParameters(params), + Parameters: paramsRaw, }, } - got := defs[0] - if got.Type != want.Type { t.Errorf("Type: want %q, got %q", want.Type, got.Type) } - if got.Function.Name != want.Function.Name { t.Errorf("Name: want %q, got %q", want.Function.Name, got.Function.Name) } - if got.Function.Description != want.Function.Description { t.Errorf("Description: want %q, got %q", want.Function.Description, got.Function.Description) } @@ -391,23 +264,18 @@ func TestToolRegistry_ToProviderDefs(t *testing.T) { func TestToolRegistry_List(t *testing.T) { r := NewToolRegistry() - r.Register(newMockTool("x", "")) - r.Register(newMockTool("y", "")) names := r.List() - if len(names) != 2 { t.Fatalf("expected 2 names, got %d", len(names)) } nameSet := map[string]bool{} - for _, n := range names { nameSet[n] = true } - if !nameSet["x"] || !nameSet["y"] { t.Errorf("expected names {x, y}, got %v", names) } @@ -415,207 +283,55 @@ func TestToolRegistry_List(t *testing.T) { func TestToolRegistry_Count(t *testing.T) { r := NewToolRegistry() - if r.Count() != 0 { t.Errorf("expected 0, got %d", r.Count()) } r.Register(newMockTool("a", "")) - r.Register(newMockTool("b", "")) - if r.Count() != 2 { t.Errorf("expected 2, got %d", r.Count()) } r.Register(newMockTool("a", "replaced")) - if r.Count() != 2 { t.Errorf("expected 2 after overwrite, got %d", r.Count()) } } -func TestBuildParamHint(t *testing.T) { - tests := []struct { - name string - - schema map[string]any - - want string - }{ - { - name: "required and optional", - - schema: map[string]any{ - "type": "object", - - "properties": map[string]any{ - "task": map[string]any{"type": "string"}, - - "label": map[string]any{"type": "string"}, - }, - - "required": []string{"task"}, - }, - - want: "(task, label?)", - }, - - { - name: "all required", - - schema: map[string]any{ - "type": "object", - - "properties": map[string]any{ - "command": map[string]any{"type": "string"}, - }, - - "required": []string{"command"}, - }, - - want: "(command)", - }, - - { - name: "no properties", - - schema: map[string]any{ - "type": "object", - }, - - want: "", - }, - - { - name: "empty schema", - - schema: map[string]any{}, - - want: "", - }, - - { - name: "nil schema", - - schema: nil, - - want: "", - }, - - { - name: "multiple optional sorted", - - schema: map[string]any{ - "type": "object", - - "properties": map[string]any{ - "task": map[string]any{"type": "string"}, - - "preset": map[string]any{"type": "string"}, - - "label": map[string]any{"type": "string"}, - - "agent_id": map[string]any{"type": "string"}, - }, - - "required": []string{"task"}, - }, - - want: "(task, agent_id?, label?, preset?)", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := buildParamHint(tt.schema) - - if got != tt.want { - t.Errorf("buildParamHint() = %q, want %q", got, tt.want) - } - }) - } -} - func TestToolRegistry_GetSummaries(t *testing.T) { r := NewToolRegistry() - r.Register(newMockTool("read_file", "Reads a file")) summaries := r.GetSummaries() - if len(summaries) != 1 { t.Fatalf("expected 1 summary, got %d", len(summaries)) } - if !strings.Contains(summaries[0], "`read_file`") { t.Errorf("expected backtick-quoted name in summary, got %q", summaries[0]) } - if !strings.Contains(summaries[0], "Reads a file") { t.Errorf("expected description in summary, got %q", summaries[0]) } } -func TestToolRegistry_GetSummaries_WithParamHint(t *testing.T) { - r := NewToolRegistry() - - r.Register(&mockRegistryTool{ - name: "spawn", - - desc: "Spawn a subagent", - - params: map[string]any{ - "type": "object", - - "properties": map[string]any{ - "task": map[string]any{"type": "string"}, - - "preset": map[string]any{"type": "string"}, - }, - - "required": []string{"task"}, - }, - - result: SilentResult("ok"), - }) - - summaries := r.GetSummaries() - - if len(summaries) != 1 { - t.Fatalf("expected 1 summary, got %d", len(summaries)) - } - - // Should contain param hint - - if !strings.Contains(summaries[0], "(task, preset?)") { - t.Errorf("expected param hint in summary, got %q", summaries[0]) - } -} - func TestToolToSchema(t *testing.T) { tool := newMockTool("demo", "demo tool") - schema := ToolToSchema(tool) if schema["type"] != "function" { t.Errorf("expected type 'function', got %v", schema["type"]) } - fn, ok := schema["function"].(map[string]any) - if !ok { t.Fatal("expected 'function' to be a map") } - if fn["name"] != "demo" { t.Errorf("expected name 'demo', got %v", fn["name"]) } - if fn["description"] != "demo tool" { t.Errorf("expected description 'demo tool', got %v", fn["description"]) } - if fn["parameters"] == nil { t.Error("expected parameters to be set") } @@ -623,25 +339,17 @@ func TestToolToSchema(t *testing.T) { func TestToolRegistry_ConcurrentAccess(t *testing.T) { r := NewToolRegistry() - var wg sync.WaitGroup for i := range 50 { wg.Add(1) - go func(n int) { defer wg.Done() - name := string(rune('A' + n%26)) - r.Register(newMockTool(name, "concurrent")) - r.Get(name) - r.Count() - r.List() - r.GetDefinitions() }(i) } diff --git a/pkg/tools/result_test.go b/pkg/tools/result_test.go index ac7d1bfda..a234e33f3 100644 --- a/pkg/tools/result_test.go +++ b/pkg/tools/result_test.go @@ -12,15 +12,12 @@ func TestNewToolResult(t *testing.T) { if result.ForLLM != "test content" { t.Errorf("Expected ForLLM 'test content', got '%s'", result.ForLLM) } - if result.Silent { t.Error("Expected Silent to be false") } - if result.IsError { t.Error("Expected IsError to be false") } - if result.Async { t.Error("Expected Async to be false") } @@ -32,15 +29,12 @@ func TestSilentResult(t *testing.T) { if result.ForLLM != "silent operation" { t.Errorf("Expected ForLLM 'silent operation', got '%s'", result.ForLLM) } - if !result.Silent { t.Error("Expected Silent to be true") } - if result.IsError { t.Error("Expected IsError to be false") } - if result.Async { t.Error("Expected Async to be false") } @@ -52,15 +46,12 @@ func TestAsyncResult(t *testing.T) { if result.ForLLM != "async task started" { t.Errorf("Expected ForLLM 'async task started', got '%s'", result.ForLLM) } - if result.Silent { t.Error("Expected Silent to be false") } - if result.IsError { t.Error("Expected IsError to be false") } - if !result.Async { t.Error("Expected Async to be true") } @@ -72,15 +63,12 @@ func TestErrorResult(t *testing.T) { if result.ForLLM != "operation failed" { t.Errorf("Expected ForLLM 'operation failed', got '%s'", result.ForLLM) } - if result.Silent { t.Error("Expected Silent to be false") } - if !result.IsError { t.Error("Expected IsError to be true") } - if result.Async { t.Error("Expected Async to be false") } @@ -88,25 +76,20 @@ func TestErrorResult(t *testing.T) { func TestUserResult(t *testing.T) { content := "user visible message" - result := UserResult(content) if result.ForLLM != content { t.Errorf("Expected ForLLM '%s', got '%s'", content, result.ForLLM) } - if result.ForUser != content { t.Errorf("Expected ForUser '%s', got '%s'", content, result.ForUser) } - if result.Silent { t.Error("Expected Silent to be false") } - if result.IsError { t.Error("Expected IsError to be false") } - if result.Async { t.Error("Expected Async to be false") } @@ -114,37 +97,27 @@ func TestUserResult(t *testing.T) { func TestToolResultJSONSerialization(t *testing.T) { tests := []struct { - name string - + name string result *ToolResult }{ { - name: "basic result", - + name: "basic result", result: NewToolResult("basic content"), }, - { - name: "silent result", - + name: "silent result", result: SilentResult("silent content"), }, - { - name: "async result", - + name: "async result", result: AsyncResult("async content"), }, - { - name: "error result", - + name: "error result", result: ErrorResult("error content"), }, - { - name: "user result", - + name: "user result", result: UserResult("user content"), }, } @@ -152,38 +125,30 @@ func TestToolResultJSONSerialization(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Marshal to JSON - data, err := json.Marshal(tt.result) if err != nil { t.Fatalf("Failed to marshal: %v", err) } // Unmarshal back - var decoded ToolResult - if err := json.Unmarshal(data, &decoded); err != nil { t.Fatalf("Failed to unmarshal: %v", err) } // Verify fields match (Err should be excluded) - if decoded.ForLLM != tt.result.ForLLM { t.Errorf("ForLLM mismatch: got '%s', want '%s'", decoded.ForLLM, tt.result.ForLLM) } - if decoded.ForUser != tt.result.ForUser { t.Errorf("ForUser mismatch: got '%s', want '%s'", decoded.ForUser, tt.result.ForUser) } - if decoded.Silent != tt.result.Silent { t.Errorf("Silent mismatch: got %v, want %v", decoded.Silent, tt.result.Silent) } - if decoded.IsError != tt.result.IsError { t.Errorf("IsError mismatch: got %v, want %v", decoded.IsError, tt.result.IsError) } - if decoded.Async != tt.result.Async { t.Errorf("Async mismatch: got %v, want %v", decoded.Async, tt.result.Async) } @@ -193,27 +158,22 @@ func TestToolResultJSONSerialization(t *testing.T) { func TestToolResultWithErrors(t *testing.T) { err := errors.New("underlying error") - result := ErrorResult("error message").WithError(err) if result.Err == nil { t.Error("Expected Err to be set") } - if result.Err.Error() != "underlying error" { t.Errorf("Expected Err message 'underlying error', got '%s'", result.Err.Error()) } // Verify Err is not serialized - data, marshalErr := json.Marshal(result) - if marshalErr != nil { t.Fatalf("Failed to marshal: %v", marshalErr) } var decoded ToolResult - if unmarshalErr := json.Unmarshal(data, &decoded); unmarshalErr != nil { t.Fatalf("Failed to unmarshal: %v", unmarshalErr) } @@ -232,47 +192,37 @@ func TestToolResultJSONStructure(t *testing.T) { } // Verify JSON structure - var parsed map[string]any - if err := json.Unmarshal(data, &parsed); err != nil { t.Fatalf("Failed to parse JSON: %v", err) } // Check expected keys exist - if _, ok := parsed["for_llm"]; !ok { t.Error("Expected 'for_llm' key in JSON") } - if _, ok := parsed["for_user"]; !ok { t.Error("Expected 'for_user' key in JSON") } - if _, ok := parsed["silent"]; !ok { t.Error("Expected 'silent' key in JSON") } - if _, ok := parsed["is_error"]; !ok { t.Error("Expected 'is_error' key in JSON") } - if _, ok := parsed["async"]; !ok { t.Error("Expected 'async' key in JSON") } // Check that 'err' is NOT present (it should have json:"-" tag) - if _, ok := parsed["err"]; ok { t.Error("Expected 'err' key to be excluded from JSON") } // Verify values - if parsed["for_llm"] != "test content" { t.Errorf("Expected for_llm 'test content', got %v", parsed["for_llm"]) } - if parsed["silent"] != false { t.Errorf("Expected silent false, got %v", parsed["silent"]) } diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go new file mode 100644 index 000000000..f41c80d90 --- /dev/null +++ b/pkg/tools/search_tool.go @@ -0,0 +1,304 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "sync" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/utils" +) + +const ( + MaxRegexPatternLength = 200 +) + +type RegexSearchTool struct { + registry *ToolRegistry + ttl int + maxSearchResults int +} + +func NewRegexSearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *RegexSearchTool { + return &RegexSearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults} +} + +func (t *RegexSearchTool) Name() string { + return "tool_search_tool_regex" +} + +func (t *RegexSearchTool) Description() string { + return "Search available hidden tools on-demand using a regex pattern. Returns JSON schemas of discovered tools." +} + +func (t *RegexSearchTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "pattern": map[string]any{ + "type": "string", + "description": "Regex pattern to match tool name or description", + }, + }, + "required": []string{"pattern"}, + } +} + +func (t *RegexSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + pattern, ok := args["pattern"].(string) + if !ok || strings.TrimSpace(pattern) == "" { + // An empty string regex (?i) will match every hidden tool, + // dumping massive payloads into the context and burning tokens. + return ErrorResult("Missing or invalid 'pattern' argument. Must be a non-empty string.") + } + + if len(pattern) > MaxRegexPatternLength { + logger.WarnCF("discovery", "Regex pattern rejected (too long)", map[string]any{"len": len(pattern)}) + return ErrorResult(fmt.Sprintf("Pattern too long: max %d characters allowed", MaxRegexPatternLength)) + } + + logger.DebugCF("discovery", "Regex search", map[string]any{"pattern": pattern}) + + res, err := t.registry.SearchRegex(pattern, t.maxSearchResults) + if err != nil { + logger.WarnCF("discovery", "Invalid regex pattern", map[string]any{"pattern": pattern, "error": err.Error()}) + return ErrorResult(fmt.Sprintf("Invalid regex pattern syntax: %v. Please fix your regex and try again.", err)) + } + + logger.InfoCF("discovery", "Regex search completed", map[string]any{"pattern": pattern, "results": len(res)}) + return formatDiscoveryResponse(t.registry, res, t.ttl) +} + +type BM25SearchTool struct { + registry *ToolRegistry + ttl int + maxSearchResults int + + // Cache: rebuilt only when the registry version changes. + cacheMu sync.Mutex + cachedEngine *bm25CachedEngine + cacheVersion uint64 +} + +func NewBM25SearchTool(r *ToolRegistry, ttl int, maxSearchResults int) *BM25SearchTool { + return &BM25SearchTool{registry: r, ttl: ttl, maxSearchResults: maxSearchResults} +} + +func (t *BM25SearchTool) Name() string { + return "tool_search_tool_bm25" +} + +func (t *BM25SearchTool) Description() string { + return "Search available hidden tools on-demand using natural language query describing the action you need to perform. Returns JSON schemas of discovered tools." +} + +func (t *BM25SearchTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{ + "type": "string", + "description": "Search query", + }, + }, + "required": []string{"query"}, + } +} + +func (t *BM25SearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + query, ok := args["query"].(string) + if !ok || strings.TrimSpace(query) == "" { + // An empty string query will match every hidden tool, + // dumping massive payloads into the context and burning tokens. + return ErrorResult("Missing or invalid 'query' argument. Must be a non-empty string.") + } + + logger.DebugCF("discovery", "BM25 search", map[string]any{"query": query}) + + cached := t.getOrBuildEngine() + if cached == nil { + logger.DebugCF("discovery", "BM25 search: no hidden tools available", nil) + return SilentResult("No tools found matching the query.") + } + + ranked := cached.engine.Search(query, t.maxSearchResults) + if len(ranked) == 0 { + logger.DebugCF("discovery", "BM25 search: no matches", map[string]any{"query": query}) + return SilentResult("No tools found matching the query.") + } + + results := make([]ToolSearchResult, len(ranked)) + for i, r := range ranked { + results[i] = ToolSearchResult{ + Name: r.Document.Name, + Description: r.Document.Description, + } + } + + logger.InfoCF("discovery", "BM25 search completed", map[string]any{"query": query, "results": len(results)}) + return formatDiscoveryResponse(t.registry, results, t.ttl) +} + +// ToolSearchResult represents the result returned to the LLM. +// Parameters are omitted from the JSON response to save context tokens; +// the LLM will see full schemas via ToProviderDefs after promotion. +type ToolSearchResult struct { + Name string `json:"name"` + Description string `json:"description"` +} + +func (r *ToolRegistry) SearchRegex(pattern string, maxSearchResults int) ([]ToolSearchResult, error) { + if maxSearchResults <= 0 { + return nil, nil + } + + regex, err := regexp.Compile("(?i)" + pattern) + if err != nil { + return nil, fmt.Errorf("failed to compile regex pattern %q: %w", pattern, err) + } + + r.mu.RLock() + defer r.mu.RUnlock() + + var results []ToolSearchResult + + // Iterate in sorted order for deterministic results across calls. + for _, name := range r.sortedToolNames() { + entry := r.tools[name] + // Search only among the hidden tools (Core tools are already visible) + if !entry.IsCore { + // Directly call interface methods! No reflection/unmarshalling needed. + desc := entry.Tool.Description() + + if regex.MatchString(name) || regex.MatchString(desc) { + results = append(results, ToolSearchResult{ + Name: name, + Description: desc, + }) + if len(results) >= maxSearchResults { + break // Stop searching once we hit the max! Saves CPU. + } + } + } + } + + return results, nil +} + +func formatDiscoveryResponse(registry *ToolRegistry, results []ToolSearchResult, ttl int) *ToolResult { + if len(results) == 0 { + return SilentResult("No tools found matching the query.") + } + + names := make([]string, len(results)) + for i, r := range results { + names[i] = r.Name + } + registry.PromoteTools(names, ttl) + logger.InfoCF("discovery", "Promoted tools", map[string]any{"tools": names, "ttl": ttl}) + + b, err := json.Marshal(results) + if err != nil { + return ErrorResult("Failed to format search results: " + err.Error()) + } + + msg := fmt.Sprintf( + "Found %d tools:\n%s\n\nSUCCESS: These tools have been temporarily UNLOCKED as native tools! In your next response, you can call them directly just like any normal tool", + len(results), + string(b), + ) + + return SilentResult(msg) +} + +// Lightweight internal type used as corpus document for BM25. +type searchDoc struct { + Name string + Description string +} + +// bm25CachedEngine wraps a BM25Engine with its corpus snapshot. +type bm25CachedEngine struct { + engine *utils.BM25Engine[searchDoc] +} + +// snapshotToSearchDocs converts a HiddenToolSnapshot to BM25 searchDoc slice. +func snapshotToSearchDocs(snap HiddenToolSnapshot) []searchDoc { + docs := make([]searchDoc, len(snap.Docs)) + for i, d := range snap.Docs { + docs[i] = searchDoc{Name: d.Name, Description: d.Description} + } + return docs +} + +// buildBM25Engine creates a BM25Engine from a slice of searchDocs. +func buildBM25Engine(docs []searchDoc) *utils.BM25Engine[searchDoc] { + return utils.NewBM25Engine( + docs, + func(doc searchDoc) string { + return doc.Name + " " + doc.Description + }, + ) +} + +// getOrBuildEngine returns a cached BM25 engine, rebuilding it only when +// the registry version has changed (new tools registered). +func (t *BM25SearchTool) getOrBuildEngine() *bm25CachedEngine { + // Fast path: optimistic check without locking. + if t.cachedEngine != nil && t.cacheVersion == t.registry.Version() { + return t.cachedEngine + } + + t.cacheMu.Lock() + defer t.cacheMu.Unlock() + + // Snapshot + version are read under a single registry RLock, + // guaranteeing consistency (no TOCTOU). + snap := t.registry.SnapshotHiddenTools() + + // Re-check: another goroutine may have rebuilt while we waited for cacheMu. + if t.cachedEngine != nil && t.cacheVersion == snap.Version { + return t.cachedEngine + } + + docs := snapshotToSearchDocs(snap) + if len(docs) == 0 { + t.cachedEngine = nil + t.cacheVersion = snap.Version + return nil + } + + cached := &bm25CachedEngine{engine: buildBM25Engine(docs)} + t.cachedEngine = cached + t.cacheVersion = snap.Version + logger.DebugCF("discovery", "BM25 engine rebuilt", map[string]any{"docs": len(docs), "version": snap.Version}) + return cached +} + +// SearchBM25 ranks hidden tools against query using BM25 via utils.BM25Engine. +// This non-cached variant rebuilds the engine on every call. Used by tests +// and any code that doesn't hold a BM25SearchTool instance. +func (r *ToolRegistry) SearchBM25(query string, maxSearchResults int) []ToolSearchResult { + snap := r.SnapshotHiddenTools() + docs := snapshotToSearchDocs(snap) + if len(docs) == 0 { + return nil + } + + ranked := buildBM25Engine(docs).Search(query, maxSearchResults) + if len(ranked) == 0 { + return nil + } + + out := make([]ToolSearchResult, len(ranked)) + for i, r := range ranked { + out[i] = ToolSearchResult{ + Name: r.Document.Name, + Description: r.Document.Description, + } + } + return out +} diff --git a/pkg/tools/search_tools_test.go b/pkg/tools/search_tools_test.go new file mode 100644 index 000000000..3aae941cb --- /dev/null +++ b/pkg/tools/search_tools_test.go @@ -0,0 +1,339 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "testing" +) + +// Dummy tool to fill the registry in our tests. +type mockSearchableTool struct { + name string + desc string +} + +func (m *mockSearchableTool) Name() string { return m.name } +func (m *mockSearchableTool) Description() string { return m.desc } +func (m *mockSearchableTool) Parameters() map[string]any { + return map[string]any{"type": "object"} +} + +func (m *mockSearchableTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + return SilentResult("mock executed: " + m.name) +} + +// Helper to initialize a populated ToolRegistry +func setupPopulatedRegistry() *ToolRegistry { + reg := NewToolRegistry() + + // A core tool (NOT to be found by searches) + reg.Register(&mockSearchableTool{ + name: "core_search", + desc: "I am a visible core tool for searching files", + }) + + // Hidden tools (must be found by searches) + reg.RegisterHidden(&mockSearchableTool{ + name: "mcp_read_file", + desc: "Read the contents of a system file", + }) + reg.RegisterHidden(&mockSearchableTool{ + name: "mcp_list_dir", + desc: "List directories and files in the system", + }) + reg.RegisterHidden(&mockSearchableTool{ + name: "mcp_fetch_net", + desc: "Fetch data from a network database", + }) + + return reg +} + +func TestRegexSearchTool_Execute(t *testing.T) { + reg := setupPopulatedRegistry() + tool := NewRegexSearchTool(reg, 5, 10) + ctx := context.Background() + + t.Run("Empty Pattern Error", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{}) + if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'pattern'") { + t.Errorf("Expected missing pattern error, got: %v", res.ForLLM) + } + }) + + t.Run("Invalid Regex Syntax", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"pattern": "[unclosed"}) + if !res.IsError || !strings.Contains(res.ForLLM, "Invalid regex pattern syntax") { + t.Errorf("Expected regex syntax error, got: %v", res.ForLLM) + } + }) + + t.Run("No Match Found", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"pattern": "alien"}) + if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") { + t.Errorf("Expected 'no tools found' message, got: %v", res.ForLLM) + } + }) + + t.Run("Successful Match & Promotion", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"pattern": "system"}) + + if res.IsError { + t.Fatalf("Unexpected error: %v", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "SUCCESS: These tools have been temporarily UNLOCKED") { + t.Errorf("Expected success string, got: %v", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "mcp_read_file") { + t.Errorf("Expected 'mcp_read_file' in results") + } + + // Verify that the TTL has been updated for the tools found + reg.mu.RLock() + defer reg.mu.RUnlock() + if reg.tools["mcp_read_file"].TTL != 5 { + t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 5, got %d", reg.tools["mcp_read_file"].TTL) + } + if reg.tools["mcp_fetch_net"].TTL != 0 { + t.Errorf("Expected 'mcp_fetch_net' to NOT be promoted (TTL=0)") + } + }) +} + +func TestBM25SearchTool_Execute(t *testing.T) { + reg := setupPopulatedRegistry() + tool := NewBM25SearchTool(reg, 3, 10) + ctx := context.Background() + + t.Run("Empty Query Error", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"query": " "}) + if !res.IsError || !strings.Contains(res.ForLLM, "Missing or invalid 'query'") { + t.Errorf("Expected missing query error, got: %v", res.ForLLM) + } + }) + + t.Run("No Match Found", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"query": "aliens spaceships"}) + if res.IsError || !strings.Contains(res.ForLLM, "No tools found matching") { + t.Errorf("Expected 'no tools found', got: %v", res.ForLLM) + } + }) + + t.Run("Successful Match & Promotion", func(t *testing.T) { + res := tool.Execute(ctx, map[string]any{"query": "read files"}) + + if res.IsError { + t.Fatalf("Unexpected error: %v", res.ForLLM) + } + if !strings.Contains(res.ForLLM, "mcp_read_file") { + t.Errorf("Expected 'mcp_read_file' in BM25 results") + } + + reg.mu.RLock() + defer reg.mu.RUnlock() + if reg.tools["mcp_read_file"].TTL != 3 { + t.Errorf("Expected TTL of 'mcp_read_file' to be promoted to 3") + } + }) +} + +func TestRegexSearchTool_PatternTooLong(t *testing.T) { + reg := setupPopulatedRegistry() + tool := NewRegexSearchTool(reg, 5, 10) + ctx := context.Background() + + longPattern := strings.Repeat("a", MaxRegexPatternLength+1) + res := tool.Execute(ctx, map[string]any{"pattern": longPattern}) + if !res.IsError || !strings.Contains(res.ForLLM, "Pattern too long") { + t.Errorf("Expected pattern too long error, got: %v", res.ForLLM) + } +} + +func TestSearchRegex_ZeroMaxResults(t *testing.T) { + reg := setupPopulatedRegistry() + + res, err := reg.SearchRegex("mcp", 0) + if err != nil { + t.Fatalf("SearchRegex failed: %v", err) + } + if len(res) != 0 { + t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res)) + } +} + +func TestSearchBM25_ZeroMaxResults(t *testing.T) { + reg := setupPopulatedRegistry() + + res := reg.SearchBM25("read file", 0) + if len(res) != 0 { + t.Errorf("Expected 0 results with maxSearchResults=0, got %d", len(res)) + } +} + +func TestSearchRegex_DeterministicOrder(t *testing.T) { + reg := NewToolRegistry() + for i := 0; i < 20; i++ { + reg.RegisterHidden(&mockSearchableTool{ + name: fmt.Sprintf("tool_%02d", i), + desc: "searchable tool", + }) + } + + // Run the same search multiple times and verify order is stable + var firstRun []string + for attempt := 0; attempt < 10; attempt++ { + res, err := reg.SearchRegex("searchable", 20) + if err != nil { + t.Fatalf("SearchRegex failed: %v", err) + } + + names := make([]string, len(res)) + for i, r := range res { + names[i] = r.Name + } + + if attempt == 0 { + firstRun = names + } else { + for i, name := range names { + if name != firstRun[i] { + t.Fatalf("Non-deterministic order at attempt %d, index %d: got %q, want %q", + attempt, i, name, firstRun[i]) + } + } + } + } +} + +func TestToolRegistry_SearchLimitsAndCoreFiltering(t *testing.T) { + reg := NewToolRegistry() + + // Add 1 Core and 10 Hidden, all containing the word "match" + reg.Register(&mockSearchableTool{"core_match", "I am core with match"}) + for i := 0; i < 10; i++ { + reg.RegisterHidden(&mockSearchableTool{ + name: fmt.Sprintf("hidden_match_%d", i), + desc: "this has a match", + }) + } + + t.Run("Regex limits and core filtering", func(t *testing.T) { + // Search with Regex and a limit of maxSearchResults = 4 + res, err := reg.SearchRegex("match", 4) + if err != nil { + t.Fatalf("SearchRegex failed: %v", err) + } + + if len(res) != 4 { + t.Errorf("Expected exactly 4 results due to limit, got %d", len(res)) + } + + for _, r := range res { + if r.Name == "core_match" { + t.Errorf("SearchRegex returned a Core tool, which should be excluded") + } + } + }) + + t.Run("BM25 limits and core filtering", func(t *testing.T) { + // Search with BM25 and a limit of maxSearchResults = 3 + res := reg.SearchBM25("match", 3) + + if len(res) != 3 { + t.Errorf("Expected exactly 3 results due to limit, got %d", len(res)) + } + + for _, r := range res { + if r.Name == "core_match" { + t.Errorf("SearchBM25 returned a Core tool, which should be excluded") + } + } + }) +} + +func TestGet_HiddenToolTTLLifecycle(t *testing.T) { + reg := NewToolRegistry() + reg.RegisterHidden(&mockSearchableTool{name: "hidden_tool", desc: "test"}) + + // TTL=0 at registration → not gettable + _, ok := reg.Get("hidden_tool") + if ok { + t.Error("Expected hidden tool with TTL=0 to NOT be gettable") + } + + // Promote → gettable + reg.PromoteTools([]string{"hidden_tool"}, 3) + _, ok = reg.Get("hidden_tool") + if !ok { + t.Error("Expected promoted hidden tool to be gettable") + } + + // Tick down to 0 → not gettable again + reg.TickTTL() // 3→2 + reg.TickTTL() // 2→1 + reg.TickTTL() // 1→0 + _, ok = reg.Get("hidden_tool") + if ok { + t.Error("Expected hidden tool with TTL ticked to 0 to NOT be gettable") + } + + // Core tools remain always gettable + reg.Register(&mockSearchableTool{name: "core_tool", desc: "core"}) + _, ok = reg.Get("core_tool") + if !ok { + t.Error("Expected core tool to always be gettable") + } +} + +func TestBM25CacheInvalidation(t *testing.T) { + reg := NewToolRegistry() + reg.RegisterHidden(&mockSearchableTool{name: "tool_alpha", desc: "alpha functionality"}) + + tool := NewBM25SearchTool(reg, 5, 10) + ctx := context.Background() + + // First search should find tool_alpha + res := tool.Execute(ctx, map[string]any{"query": "alpha"}) + if !strings.Contains(res.ForLLM, "tool_alpha") { + t.Fatalf("Expected 'tool_alpha' in first search, got: %v", res.ForLLM) + } + + // Register a new hidden tool + reg.RegisterHidden(&mockSearchableTool{name: "tool_beta", desc: "beta functionality"}) + + // Cache should be invalidated; new tool should be findable + res = tool.Execute(ctx, map[string]any{"query": "beta"}) + if !strings.Contains(res.ForLLM, "tool_beta") { + t.Errorf("Expected 'tool_beta' after cache invalidation, got: %v", res.ForLLM) + } +} + +func TestPromoteTools_ConcurrentWithTickTTL(t *testing.T) { + reg := NewToolRegistry() + for i := 0; i < 20; i++ { + reg.RegisterHidden(&mockSearchableTool{ + name: fmt.Sprintf("concurrent_tool_%d", i), + desc: "concurrent test tool", + }) + } + + names := make([]string, 20) + for i := 0; i < 20; i++ { + names[i] = fmt.Sprintf("concurrent_tool_%d", i) + } + + // Hammer PromoteTools and TickTTL concurrently to detect races + done := make(chan struct{}) + go func() { + for i := 0; i < 1000; i++ { + reg.PromoteTools(names, 5) + } + close(done) + }() + + for i := 0; i < 1000; i++ { + reg.TickTTL() + } + <-done +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 45672c2cb..e2c8a5c11 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -19,6 +19,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/constants" ) const ( @@ -170,10 +171,14 @@ type ExecTool struct { allowRules [][]string // pre-split command prefix allowlist + customAllowPatterns []*regexp.Regexp + restrictToWorkspace bool localNetOnly bool // restrict curl/wget to localhost + RFC 1918 + allowRemote bool + // Background process management bgMu sync.Mutex @@ -187,100 +192,115 @@ type ExecTool struct { bgCtx context.Context } -var defaultDenyPatterns = []*regexp.Regexp{ - regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), +var ( + defaultDenyPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), - regexp.MustCompile(`\bdel\s+/[fq]\b`), + regexp.MustCompile(`\bdel\s+/[fq]\b`), - regexp.MustCompile(`\brmdir\s+/s\b`), + regexp.MustCompile(`\brmdir\s+/s\b`), - // Match disk wiping commands (must be followed by space/args) + // Match disk wiping commands (must be followed by space/args) - regexp.MustCompile( + regexp.MustCompile( + `\b(format|mkfs|diskpart)\b\s`, + ), - `\b(format|mkfs|diskpart)\b\s`, - ), + regexp.MustCompile(`\bdd\s+if=`), - regexp.MustCompile(`\bdd\s+if=`), + // Block writes to block devices (all common naming schemes). + regexp.MustCompile( + `>\s*/dev/(sd[a-z]|hd[a-z]|vd[a-z]|xvd[a-z]|nvme\d|mmcblk\d|loop\d|dm-\d|md\d|sr\d|nbd\d)`, + ), - regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) + regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), - regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), + regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), - regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), + regexp.MustCompile(`\$\([^)]+\)`), - regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`\$\{[^}]+\}`), - regexp.MustCompile(`\$\{[^}]+\}`), + regexp.MustCompile("`[^`]+`"), - regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\|\s*sh\b`), - regexp.MustCompile(`\|\s*sh\b`), + regexp.MustCompile(`\|\s*bash\b`), - regexp.MustCompile(`\|\s*bash\b`), + regexp.MustCompile(`;\s*rm\s+-[rf]`), - regexp.MustCompile(`;\s*rm\s+-[rf]`), + regexp.MustCompile(`&&\s*rm\s+-[rf]`), - regexp.MustCompile(`&&\s*rm\s+-[rf]`), + regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), - regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), + regexp.MustCompile(`<<\s*EOF`), - regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), + regexp.MustCompile(`\$\(\s*cat\s+`), - regexp.MustCompile(`<<\s*EOF`), + regexp.MustCompile(`\$\(\s*curl\s+`), - regexp.MustCompile(`\$\(\s*cat\s+`), + regexp.MustCompile(`\$\(\s*wget\s+`), - regexp.MustCompile(`\$\(\s*curl\s+`), + regexp.MustCompile(`\$\(\s*which\s+`), - regexp.MustCompile(`\$\(\s*wget\s+`), + regexp.MustCompile(`\bsudo\b`), - regexp.MustCompile(`\$\(\s*which\s+`), + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), - regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bchown\b`), - regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), + regexp.MustCompile(`\bpkill\b`), - regexp.MustCompile(`\bchown\b`), + regexp.MustCompile(`\bkillall\b`), - regexp.MustCompile(`\bpkill\b`), + regexp.MustCompile(`\bkill\b`), - regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bkill\s+-[9]\b`), + regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bnpm\s+install\s+-g\b`), - regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bpip\s+install\s+--user\b`), - regexp.MustCompile(`\bnpm\s+install\s+-g\b`), + regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), - regexp.MustCompile(`\bpip\s+install\s+--user\b`), + regexp.MustCompile(`\byum\s+(install|remove)\b`), - regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), + regexp.MustCompile(`\bdnf\s+(install|remove)\b`), - regexp.MustCompile(`\byum\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+run\b`), - regexp.MustCompile(`\bdnf\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), - regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bgit\s+push\b`), - regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+force\b`), - regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+checkout\b`), - regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bgit\s+switch\b`), - regexp.MustCompile(`\bgit\s+checkout\b`), + regexp.MustCompile(`\bssh\b.*@`), - regexp.MustCompile(`\bgit\s+switch\b`), + regexp.MustCompile(`\beval\b`), - regexp.MustCompile(`\bssh\b.*@`), + regexp.MustCompile(`\bsource\s+.*\.sh\b`), + } - regexp.MustCompile(`\beval\b`), - - regexp.MustCompile(`\bsource\s+.*\.sh\b`), -} + // safePaths are kernel pseudo-devices that are always safe to reference in + // commands, regardless of workspace restriction. They contain no user data + // and cannot cause destructive writes. + safePaths = map[string]bool{ + "/dev/null": true, + "/dev/zero": true, + "/dev/random": true, + "/dev/urandom": true, + "/dev/stdin": true, + "/dev/stdout": true, + "/dev/stderr": true, + } +) func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) { return NewExecToolWithConfig(workingDir, restrict, nil) @@ -288,49 +308,63 @@ func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) { func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) (*ExecTool, error) { denyPatterns := make([]*regexp.Regexp, 0) + customAllowPatterns := make([]*regexp.Regexp, 0) + allowRemote := true if config != nil { execConfig := config.Tools.Exec - enableDenyPatterns := execConfig.EnableDenyPatterns + allowRemote = execConfig.AllowRemote if enableDenyPatterns { denyPatterns = append(denyPatterns, defaultDenyPatterns...) - if len(execConfig.CustomDenyPatterns) > 0 { fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) - for _, pattern := range execConfig.CustomDenyPatterns { re, err := regexp.Compile(pattern) if err != nil { return nil, fmt.Errorf("invalid custom deny pattern %q: %w", pattern, err) } - denyPatterns = append(denyPatterns, re) } } } else { // If deny patterns are disabled, we won't add any patterns, allowing all commands. - fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") } + for _, pattern := range execConfig.CustomAllowPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + return nil, fmt.Errorf("invalid custom allow pattern %q: %w", pattern, err) + } + customAllowPatterns = append(customAllowPatterns, re) + } } else { denyPatterns = append(denyPatterns, defaultDenyPatterns...) } + timeout := 5 * time.Minute + if config != nil && config.Tools.Exec.TimeoutSeconds > 0 { + timeout = time.Duration(config.Tools.Exec.TimeoutSeconds) * time.Second + } + bgCtx, bgCancel := context.WithCancel(context.Background()) return &ExecTool{ workingDir: workingDir, - timeout: 5 * time.Minute, + timeout: timeout, denyPatterns: denyPatterns, allowRules: nil, + customAllowPatterns: customAllowPatterns, + restrictToWorkspace: restrict, + allowRemote: allowRemote, + bgProcesses: make(map[string]*bgProcess), bgCtx: bgCtx, @@ -350,14 +384,12 @@ func (t *ExecTool) Description() string { func (t *ExecTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "command": map[string]any{ "type": "string", "description": "The shell command to execute", }, - "working_dir": map[string]any{ "type": "string", @@ -408,6 +440,19 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult return ErrorResult("command is required") } + // GHSA-pv8c-p6jf-3fpp: block exec from remote channels (e.g. Telegram webhooks) + // unless explicitly opted-in via config. Fail-closed: empty channel = blocked. + if !t.allowRemote { + channel := ToolChannel(ctx) + if channel == "" { + channel, _ = args["__channel"].(string) + } + channel = strings.TrimSpace(channel) + if channel == "" || !constants.IsInternalChannel(channel) { + return ErrorResult("exec is restricted to internal channels") + } + } + cwd := t.workingDir if override := WorkspaceOverrideFromCtx(ctx); override != "" { @@ -420,7 +465,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult if err != nil { return ErrorResult("Command blocked by safety guard (" + err.Error() + ")") } - cwd = resolvedWD } else { cwd = wd @@ -429,7 +473,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult if cwd == "" { wd, err := os.Getwd() - if err == nil { cwd = wd } @@ -439,6 +482,25 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult return ErrorResult(guardError) } + // Re-resolve symlinks immediately before execution to shrink the TOCTOU window + // between validation and cmd.Dir assignment. + if t.restrictToWorkspace && t.workingDir != "" && cwd != t.workingDir { + resolved, err := filepath.EvalSymlinks(cwd) + if err != nil { + return ErrorResult(fmt.Sprintf("Command blocked by safety guard (path resolution failed: %v)", err)) + } + absWorkspace, _ := filepath.Abs(t.workingDir) + wsResolved, _ := filepath.EvalSymlinks(absWorkspace) + if wsResolved == "" { + wsResolved = absWorkspace + } + rel, err := filepath.Rel(wsResolved, resolved) + if err != nil || !filepath.IsLocal(rel) { + return ErrorResult("Command blocked by safety guard (working directory escaped workspace)") + } + cwd = resolved + } + if bg { return t.executeBg(command, cwd) } @@ -450,27 +512,21 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolResult { // timeout == 0 means no timeout - var cmdCtx context.Context - var cancel context.CancelFunc - if t.timeout > 0 { cmdCtx, cancel = context.WithTimeout(ctx, t.timeout) } else { cmdCtx, cancel = context.WithCancel(ctx) } - defer cancel() var cmd *exec.Cmd - if runtime.GOOS == "windows" { cmd = exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", command) } else { cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) } - if cwd != "" { cmd.Dir = cwd } @@ -478,9 +534,7 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe prepareCommandForTermination(cmd) var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr if err := cmd.Start(); err != nil { @@ -488,29 +542,21 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe } done := make(chan error, 1) - go func() { done <- cmd.Wait() }() var err error - select { case err = <-done: - case <-cmdCtx.Done(): - _ = terminateProcessTree(cmd) - select { case err = <-done: - case <-time.After(2 * time.Second): - if cmd.Process != nil { _ = cmd.Process.Kill() } - err = <-done } } @@ -528,12 +574,10 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe if err != nil { if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { msg := fmt.Sprintf("Command timed out after %v", t.timeout) - return &ToolResult{ ForLLM: msg, ForUser: msg, - IsError: true, } } @@ -548,7 +592,6 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe } maxLen := 10000 - if len(output) > maxLen { output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen) } @@ -558,7 +601,6 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe ForLLM: output, ForUser: output, - IsError: true, } } @@ -567,7 +609,6 @@ func (t *ExecTool) executeSync(ctx context.Context, command, cwd string) *ToolRe ForLLM: output, ForUser: output, - IsError: false, } } @@ -971,12 +1012,22 @@ func (t *ExecTool) Shutdown() { func (t *ExecTool) guardCommand(command, cwd string) string { cmd := strings.TrimSpace(command) - lower := strings.ToLower(cmd) - for _, pattern := range t.denyPatterns { + // Custom allow patterns exempt a command from deny checks. + explicitlyAllowed := false + for _, pattern := range t.customAllowPatterns { if pattern.MatchString(lower) { - return fmt.Sprintf("Command blocked: deny pattern %s", pattern.String()) + explicitlyAllowed = true + break + } + } + + if !explicitlyAllowed { + for _, pattern := range t.denyPatterns { + if pattern.MatchString(lower) { + return "Command blocked by safety guard (dangerous pattern detected)" + } } } @@ -1051,6 +1102,10 @@ func (t *ExecTool) guardCommand(command, cwd string) string { p := filepath.Clean(token) + if safePaths[p] { + continue + } + rel, err := filepath.Rel(cwdPath, p) if err != nil { continue diff --git a/pkg/tools/shell_ext_test.go b/pkg/tools/shell_ext_test.go new file mode 100644 index 000000000..61c4b5705 --- /dev/null +++ b/pkg/tools/shell_ext_test.go @@ -0,0 +1,890 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" + "time" +) + +func TestGuardCommand_RelativePathWithSlashes(t *testing.T) { + workspace := t.TempDir() + + tool, _ := NewExecTool(workspace, true) + + cmds := []string{ + "pytest tests/cold/test_solver.py -v --tb=short", + + "cd projects/terra-py-form && pytest", + + "uv run pytest tests/cold/test_solver.py -v --tb=short", + + "cat src/terra_py_form/cold/parser.py", + + "python src/main.py --config config/dev.json", + } + + for _, cmd := range cmds { + result := tool.guardCommand(cmd, workspace) + + if result != "" { + t.Errorf("Relative path should not be blocked: %q → %s", cmd, result) + } + } +} + +func TestGuardCommand_VenvBinary(t *testing.T) { + workspace := t.TempDir() + + tool, _ := NewExecTool(workspace, true) + + cmds := []string{ + ".venv/bin/python -m pytest", + + ".venv/bin/pytest tests/ -v", + + ".venv/bin/pip install -e .", + } + + for _, cmd := range cmds { + result := tool.guardCommand(cmd, workspace) + + if result != "" { + t.Errorf("Venv relative path should not be blocked: %q → %s", cmd, result) + } + } +} + +func TestGuardCommand_ExecutableBinaryAllowed(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix executable permission test not applicable on Windows") + } + + workspace := t.TempDir() + + externalDir := t.TempDir() + + execPath := filepath.Join(externalDir, "mybin") + + os.WriteFile(execPath, []byte("#!/bin/sh\necho ok"), 0o755) + + tool, _ := NewExecTool(workspace, true) + + cmd := execPath + " --help" + + result := tool.guardCommand(cmd, workspace) + + if result != "" { + t.Errorf("Executable binary outside workspace should be allowed: %q → %s", cmd, result) + } +} + +func TestGuardCommand_ExecutableBinaryAllowed_Windows(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("Windows-specific test") + } + + workspace := t.TempDir() + + externalDir := t.TempDir() + + execPath := filepath.Join(externalDir, "tool.exe") + + os.WriteFile(execPath, []byte("MZ"), 0o644) + + tool, _ := NewExecTool(workspace, true) + + cmd := execPath + " --version" + + result := tool.guardCommand(cmd, workspace) + + if result != "" { + t.Errorf("Windows .exe outside workspace should be allowed: %q → %s", cmd, result) + } +} + +func TestGuardCommand_NonExecutableOutsideBlocked(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission test not applicable on Windows") + } + + workspace := t.TempDir() + + externalDir := t.TempDir() + + dataFile := filepath.Join(externalDir, "secret.txt") + + os.WriteFile(dataFile, []byte("secret data"), 0o644) + + tool, _ := NewExecTool(workspace, true) + + cmd := "cat " + dataFile + + result := tool.guardCommand(cmd, workspace) + + if result == "" { + t.Errorf("Non-executable file outside workspace should be blocked: %q", cmd) + } + + if !strings.Contains(result, "path outside working dir") { + t.Errorf("Expected 'path outside working dir' message, got: %s", result) + } +} + +func TestGuardCommand_NonExistentAbsolutePathBlocked(t *testing.T) { + workspace := t.TempDir() + + tool, _ := NewExecTool(workspace, true) + + var cmd string + + if runtime.GOOS == "windows" { + cmd = "echo hello > C:\\nonexistent_picoclaw_test_output" + } else { + cmd = "echo hello > /tmp/nonexistent_picoclaw_test_output" + } + + result := tool.guardCommand(cmd, workspace) + + if result == "" { + t.Errorf("Non-existent absolute path outside workspace should be blocked: %q", cmd) + } +} + +func TestGuardCommand_FlagEmbeddedPathSkipped(t *testing.T) { + workspace := t.TempDir() + + tool, _ := NewExecTool(workspace, true) + + cmds := []string{ + "gcc -I/usr/local/include -L/usr/lib main.c", + + "g++ -std=c++17 -I/opt/include file.cpp", + + "python --prefix=/usr/local script.py", + } + + for _, cmd := range cmds { + result := tool.guardCommand(cmd, workspace) + + if result != "" { + t.Errorf("Flag-embedded path should not be blocked: %q → %s", cmd, result) + } + } +} + +func TestGuardCommand_AbsolutePathInsideWorkspace(t *testing.T) { + workspace := t.TempDir() + + tool, _ := NewExecTool(workspace, true) + + innerDir := filepath.Join(workspace, "projects", "myapp") + + os.MkdirAll(innerDir, 0o755) + + cmd := "ls " + innerDir + + result := tool.guardCommand(cmd, workspace) + + if result != "" { + t.Errorf("Absolute path inside workspace should be allowed: %q → %s", cmd, result) + } +} + +func TestGuardCommand_PathTraversal(t *testing.T) { + workspace := t.TempDir() + + tool, _ := NewExecTool(workspace, true) + + cmds := []string{ + "cat ../../etc/passwd", + + "cat ../../../etc/shadow", + + "ls projects/../../../../etc", + } + + for _, cmd := range cmds { + result := tool.guardCommand(cmd, workspace) + + if result == "" { + t.Errorf("Path traversal should be blocked: %q", cmd) + } + + if !strings.Contains(result, "path traversal") { + t.Errorf("Expected 'path traversal' message, got: %s", result) + } + } +} + +func TestGuardCommand_CdWithAbsoluteWorkspacePath(t *testing.T) { + workspace := t.TempDir() + + innerDir := filepath.Join(workspace, "projects", "foo") + + os.MkdirAll(innerDir, 0o755) + + tool, _ := NewExecTool(workspace, true) + + cmd := "cd " + innerDir + " && ls -la" + + result := tool.guardCommand(cmd, workspace) + + if result != "" { + t.Errorf("cd to workspace subdir should be allowed: %q → %s", cmd, result) + } +} + +func TestGuardCommand_AgentCLISlashCommand(t *testing.T) { + workspace := t.TempDir() + + tool, _ := NewExecTool(workspace, true) + + cmds := []string{ + `codex exec --yolo "/review skip-git-repo-check"`, + + `claude "/review"`, + + `gemini "/help"`, + } + + for _, cmd := range cmds { + result := tool.guardCommand(cmd, workspace) + + if result != "" { + t.Errorf("Agent CLI slash command should not be blocked: %q → %s", cmd, result) + } + } + + if runtime.GOOS != "windows" { + blocked := `cat /etc/hosts` + + result := tool.guardCommand(blocked, workspace) + + if result == "" { + t.Errorf("Non-agent command with absolute path should be blocked: %q", blocked) + } + } +} + +func TestGuardCommand_DenyPattern_IncludesPattern(t *testing.T) { + workspace := t.TempDir() + + tool, _ := NewExecTool(workspace, true) + + tool.denyPatterns = append(tool.denyPatterns, regexp.MustCompile(`\bdangerous_cmd\b`)) + + result := tool.guardCommand("dangerous_cmd --force", workspace) + + if result == "" { + t.Fatal("expected deny pattern to block the command") + } + + if !strings.Contains(result, "blocked") { + t.Errorf("expected 'blocked' in message, got: %s", result) + } +} + +func TestGuardCommand_Allowlist_ShowsRules(t *testing.T) { + workspace := t.TempDir() + + tool, _ := NewExecTool(workspace, true) + + tool.SetAllowRules([]string{"go test", "git"}) + + result := tool.guardCommand("curl http://example.com", workspace) + + if result == "" { + t.Fatal("expected allowlist to block the command") + } + + if !strings.Contains(result, "not in allowlist") { + t.Errorf("expected 'not in allowlist' in message, got: %s", result) + } + + if !strings.Contains(result, "go test") || !strings.Contains(result, "git") { + t.Errorf("expected allowlist rules in message, got: %s", result) + } +} + +func TestGuardCommand_PathOutside_IncludesPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix absolute path test not applicable on Windows") + } + + workspace := t.TempDir() + + externalDir := t.TempDir() + + dataFile := filepath.Join(externalDir, "secret.txt") + + os.WriteFile(dataFile, []byte("secret"), 0o644) + + tool, _ := NewExecTool(workspace, true) + + result := tool.guardCommand("cat "+dataFile, workspace) + + if result == "" { + t.Fatal("expected path outside workspace to be blocked") + } + + if !strings.Contains(result, "path outside working dir") { + t.Errorf("expected 'path outside working dir' in message, got: %s", result) + } + + if !strings.Contains(result, dataFile) { + t.Errorf("expected offending path %q in message, got: %s", dataFile, result) + } +} + +func TestExecTool_Bg_StartAndOutput(t *testing.T) { + tool, _ := NewExecTool("", false) + + defer tool.Shutdown() + + var cmd string + + if runtime.GOOS == "windows" { + cmd = "Write-Output 'hello from bg'; Start-Sleep -Seconds 30" + } else { + cmd = "echo 'hello from bg'; sleep 30" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + + "background": true, + }) + + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + + if !strings.Contains(result.ForLLM, "bg-1") { + t.Errorf("expected bg-1 in result, got: %s", result.ForLLM) + } + + if !strings.Contains(result.ForLLM, "Background process started") { + t.Errorf("expected start message, got: %s", result.ForLLM) + } + + outputResult := tool.Execute(context.Background(), map[string]any{ + "bg_action": "output", + + "bg_id": "bg-1", + }) + + if outputResult.IsError { + t.Fatalf("failed to get output: %s", outputResult.ForLLM) + } + + if !strings.Contains(outputResult.ForLLM, "hello from bg") { + t.Errorf("expected 'hello from bg' in output, got: %s", outputResult.ForLLM) + } + + if !strings.Contains(outputResult.ForLLM, "running") { + t.Errorf("expected 'running' status, got: %s", outputResult.ForLLM) + } +} + +func TestExecTool_Bg_Kill(t *testing.T) { + tool, _ := NewExecTool("", false) + + defer tool.Shutdown() + + var cmd string + + if runtime.GOOS == "windows" { + cmd = "Start-Sleep -Seconds 60" + } else { + cmd = "sleep 60" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + + "background": true, + }) + + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + + killResult := tool.Execute(context.Background(), map[string]any{ + "bg_action": "kill", + + "bg_id": "bg-1", + }) + + if killResult.IsError { + t.Fatalf("failed to kill: %s", killResult.ForLLM) + } + + if !strings.Contains(killResult.ForLLM, "terminated") { + t.Errorf("expected 'terminated' message, got: %s", killResult.ForLLM) + } + + procs := tool.BgProcesses() + + if _, ok := procs["bg-1"]; ok { + t.Errorf("expected bg-1 to be removed after kill") + } +} + +func TestExecTool_Bg_ExitedProcess(t *testing.T) { + tool, _ := NewExecTool("", false) + + defer tool.Shutdown() + + var cmd string + + if runtime.GOOS == "windows" { + cmd = "Write-Output 'quick exit'" + } else { + cmd = "echo 'quick exit'" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + + "background": true, + }) + + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + + time.Sleep(4 * time.Second) + + outputResult := tool.Execute(context.Background(), map[string]any{ + "bg_action": "output", + + "bg_id": "bg-1", + }) + + if outputResult.IsError { + t.Fatalf("failed to get output: %s", outputResult.ForLLM) + } + + if !strings.Contains(outputResult.ForLLM, "exited") { + t.Errorf("expected 'exited' in output, got: %s", outputResult.ForLLM) + } + + if !strings.Contains(outputResult.ForLLM, "quick exit") { + t.Errorf("expected 'quick exit' in output, got: %s", outputResult.ForLLM) + } +} + +func TestExecTool_Bg_InvalidID(t *testing.T) { + tool, _ := NewExecTool("", false) + + defer tool.Shutdown() + + result := tool.Execute(context.Background(), map[string]any{ + "bg_action": "output", + + "bg_id": "bg-999", + }) + + if !result.IsError { + t.Fatalf("expected error for invalid bg_id") + } + + if !strings.Contains(result.ForLLM, "not found") { + t.Errorf("expected 'not found' message, got: %s", result.ForLLM) + } + + result = tool.Execute(context.Background(), map[string]any{ + "bg_action": "kill", + + "bg_id": "bg-999", + }) + + if !result.IsError { + t.Fatalf("expected error for invalid bg_id") + } +} + +func TestExecTool_Bg_InitialOutputCapture(t *testing.T) { + tool, _ := NewExecTool("", false) + + defer tool.Shutdown() + + var cmd string + + if runtime.GOOS == "windows" { + cmd = "Write-Output 'initial line 1'; Write-Output 'initial line 2'; Start-Sleep -Seconds 30" + } else { + cmd = "echo 'initial line 1'; echo 'initial line 2'; sleep 30" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + + "background": true, + }) + + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + + if !strings.Contains(result.ForLLM, "initial line 1") { + t.Errorf("expected 'initial line 1' in initial output, got: %s", result.ForLLM) + } + + if !strings.Contains(result.ForLLM, "initial line 2") { + t.Errorf("expected 'initial line 2' in initial output, got: %s", result.ForLLM) + } +} + +func TestExecTool_Bg_RuntimeStatus(t *testing.T) { + tool, _ := NewExecTool("", false) + + defer tool.Shutdown() + + if s := tool.RuntimeStatus(); s != "" { + t.Errorf("expected empty runtime status with no bg processes, got: %s", s) + } + + var cmd string + + if runtime.GOOS == "windows" { + cmd = "Start-Sleep -Seconds 30" + } else { + cmd = "sleep 30" + } + + tool.Execute(context.Background(), map[string]any{ + "command": cmd, + + "background": true, + }) + + status := tool.RuntimeStatus() + + if !strings.Contains(status, "Background Processes") { + t.Errorf("expected 'Background Processes' section, got: %s", status) + } + + if !strings.Contains(status, "bg-1") { + t.Errorf("expected 'bg-1' in status, got: %s", status) + } + + if !strings.Contains(status, "running") { + t.Errorf("expected 'running' in status, got: %s", status) + } +} + +func TestExecTool_Bg_Shutdown(t *testing.T) { + tool, _ := NewExecTool("", false) + + var cmd string + + if runtime.GOOS == "windows" { + cmd = "Start-Sleep -Seconds 60" + } else { + cmd = "sleep 60" + } + + tool.Execute(context.Background(), map[string]any{ + "command": cmd, + + "background": true, + }) + + tool.Execute(context.Background(), map[string]any{ + "command": cmd, + + "background": true, + }) + + procs := tool.BgProcesses() + + for _, bp := range procs { + if !bp.isRunning() { + t.Errorf("expected process to be running before shutdown") + } + } + + tool.Shutdown() + + procs = tool.BgProcesses() + + for _, bp := range procs { + if bp.isRunning() { + t.Errorf("expected process to be stopped after shutdown") + } + } +} + +func TestRingBuffer(t *testing.T) { + t.Run("Write and String", func(t *testing.T) { + rb := newRingBuffer(100) + + rb.Write([]byte("hello ")) + + rb.Write([]byte("world")) + + if got := rb.String(); got != "hello world" { + t.Errorf("expected 'hello world', got %q", got) + } + }) + + t.Run("Lines", func(t *testing.T) { + rb := newRingBuffer(100) + + rb.Write([]byte("line1\nline2\nline3\nline4\nline5\n")) + + lines := rb.Lines(3) + + if len(lines) != 3 { + t.Fatalf("expected 3 lines, got %d", len(lines)) + } + + if lines[0] != "line3" || lines[1] != "line4" || lines[2] != "line5" { + t.Errorf("unexpected lines: %v", lines) + } + }) + + t.Run("Match", func(t *testing.T) { + rb := newRingBuffer(100) + + rb.Write([]byte("starting...\nServer ready on port 3000\nwaiting...\n")) + + re := regexp.MustCompile(`ready.*port`) + + match := rb.Match(re) + + if match == "" { + t.Fatal("expected match but got empty string") + } + + if !strings.Contains(match, "ready") { + t.Errorf("expected match to contain 'ready', got: %s", match) + } + + re2 := regexp.MustCompile(`never_match`) + + match2 := rb.Match(re2) + + if match2 != "" { + t.Errorf("expected no match, got: %s", match2) + } + }) + + t.Run("Overflow", func(t *testing.T) { + rb := newRingBuffer(10) + + rb.Write([]byte("1234567890ABCDEF")) + + got := rb.String() + + if len(got) != 10 { + t.Errorf("expected buffer to be 10 bytes, got %d", len(got)) + } + + if got != "7890ABCDEF" { + t.Errorf("expected '7890ABCDEF', got %q", got) + } + }) + + t.Run("Len", func(t *testing.T) { + rb := newRingBuffer(100) + + if rb.Len() != 0 { + t.Errorf("expected 0 length initially") + } + + rb.Write([]byte("hello")) + + if rb.Len() != 5 { + t.Errorf("expected 5, got %d", rb.Len()) + } + }) + + t.Run("Empty Lines", func(t *testing.T) { + rb := newRingBuffer(100) + + lines := rb.Lines(5) + + if lines != nil { + t.Errorf("expected nil for empty buffer, got: %v", lines) + } + }) +} + +func TestExecTool_Bg_RingBufferOverflow(t *testing.T) { + tool, _ := NewExecTool("", false) + + defer tool.Shutdown() + + var cmd string + + if runtime.GOOS == "windows" { + cmd = "1..2000 | ForEach-Object { Write-Output ('x' * 50) }; Start-Sleep -Seconds 30" + } else { + cmd = "yes 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' | head -n 2000; sleep 30" + } + + result := tool.Execute(context.Background(), map[string]any{ + "command": cmd, + + "background": true, + }) + + if result.IsError { + t.Fatalf("failed to start bg process: %s", result.ForLLM) + } + + time.Sleep(5 * time.Second) + + outputResult := tool.Execute(context.Background(), map[string]any{ + "bg_action": "output", + + "bg_id": "bg-1", + }) + + if outputResult.IsError { + t.Fatalf("failed to get output: %s", outputResult.ForLLM) + } + + procs := tool.BgProcesses() + + bp := procs["bg-1"] + + if bp == nil { + t.Fatal("bg-1 not found") + } + + bufLen := bp.output.Len() + + if bufLen > bgRingBufSize { + t.Errorf("ring buffer exceeded max size: %d > %d", bufLen, bgRingBufSize) + } +} + +func TestIsLocalHost(t *testing.T) { + tests := []struct { + host string + + want bool + }{ + {"localhost", true}, + + {"LOCALHOST", true}, + + {"127.0.0.1", true}, + + {"127.0.0.2", true}, + + {"::1", true}, + + {"10.0.0.1", true}, + + {"10.255.255.255", true}, + + {"172.16.0.1", true}, + + {"172.31.255.255", true}, + + {"192.168.0.1", true}, + + {"192.168.1.100", true}, + + {"8.8.8.8", false}, + + {"1.1.1.1", false}, + + {"example.com", false}, + + {"api.github.com", false}, + + {"172.15.255.255", false}, + + {"172.32.0.0", false}, + } + + for _, tt := range tests { + got := isLocalHost(tt.host) + + if got != tt.want { + t.Errorf("isLocalHost(%q) = %v, want %v", tt.host, got, tt.want) + } + } +} + +func TestCheckCurlLocalNet(t *testing.T) { + tests := []struct { + cmd string + + wantErr bool + }{ + {"curl http://localhost:3000/health", false}, + + {"curl -v http://127.0.0.1:8080/api/status", false}, + + {"wget http://192.168.1.10/file.bin", false}, + + {"curl -X POST http://10.0.0.5:9000/webhook", false}, + + {"curl http://example.com", true}, + + {"wget https://releases.github.com/v1.tar.gz", true}, + + {"curl http://8.8.8.8/data", true}, + + {"curl --help", false}, + + {"curl --version", false}, + + {"wget --help", false}, + } + + for _, tt := range tests { + errMsg := checkCurlLocalNet(tt.cmd) + + gotErr := errMsg != "" + + if gotErr != tt.wantErr { + t.Errorf("checkCurlLocalNet(%q): gotErr=%v wantErr=%v (msg: %q)", + + tt.cmd, gotErr, tt.wantErr, errMsg) + } + } +} + +func TestExecTool_LocalNetOnly(t *testing.T) { + tool, _ := NewExecTool("", false) + + tool.SetLocalNetOnly(true) + + tests := []struct { + cmd string + + wantErr bool + }{ + {"curl http://localhost:3000", false}, + + {"curl http://example.com", true}, + + {"echo hello", false}, + } + + ctx := context.Background() + + for _, tt := range tests { + result := tool.Execute(ctx, map[string]any{"command": tt.cmd}) + + if tt.wantErr && !result.IsError { + t.Errorf("cmd %q: expected blocked, but succeeded", tt.cmd) + } + + if !tt.wantErr && result.IsError && strings.Contains(result.ForLLM, "safety guard") { + t.Errorf("cmd %q: expected allowed, but safety guard blocked: %s", tt.cmd, result.ForLLM) + } + } +} diff --git a/pkg/tools/shell_process_unix.go b/pkg/tools/shell_process_unix.go index 5d1f52951..7b29a81bf 100644 --- a/pkg/tools/shell_process_unix.go +++ b/pkg/tools/shell_process_unix.go @@ -3,10 +3,7 @@ package tools import ( - "os" "os/exec" - "strconv" - "strings" "syscall" ) @@ -21,6 +18,7 @@ func terminateProcessTree(cmd *exec.Cmd) error { if cmd == nil || cmd.Process == nil { return nil } + pid := cmd.Process.Pid if pid <= 0 { return nil @@ -28,59 +26,7 @@ func terminateProcessTree(cmd *exec.Cmd) error { // Kill the entire process group spawned by the shell command. _ = syscall.Kill(-pid, syscall.SIGKILL) - - // Some shells/background jobs may still leave descendants around - // briefly; aggressively walk /proc and kill child processes too. - killDescendants(pid) - // Fallback kill on the shell process itself. _ = cmd.Process.Kill() - return nil } - -func killDescendants(ppid int) { - if ppid <= 0 { - return - } - entries, err := os.ReadDir("/proc") - if err != nil { - return - } - - for _, e := range entries { - if !e.IsDir() { - continue - } - childPID, err := strconv.Atoi(e.Name()) - if err != nil || childPID <= 0 || childPID == ppid { - continue - } - - statPath := "/proc/" + e.Name() + "/stat" - data, err := os.ReadFile(statPath) - if err != nil { - continue - } - - // /proc/<pid>/stat: pid (comm) state ppid ... - raw := string(data) - end := strings.LastIndex(raw, ")") - if end == -1 || end+2 >= len(raw) { - continue - } - fields := strings.Fields(raw[end+2:]) - if len(fields) < 2 { - continue - } - parent, err := strconv.Atoi(fields[1]) - if err != nil || parent != ppid { - continue - } - - // Recurse first, then kill child process/group. - killDescendants(childPID) - _ = syscall.Kill(-childPID, syscall.SIGKILL) - _ = syscall.Kill(childPID, syscall.SIGKILL) - } -} diff --git a/pkg/tools/shell_process_windows.go b/pkg/tools/shell_process_windows.go index fbd28b0fa..fe23b5c96 100644 --- a/pkg/tools/shell_process_windows.go +++ b/pkg/tools/shell_process_windows.go @@ -17,14 +17,11 @@ func terminateProcessTree(cmd *exec.Cmd) error { } pid := cmd.Process.Pid - if pid <= 0 { return nil } _ = exec.Command("taskkill", "/T", "/F", "/PID", strconv.Itoa(pid)).Run() - _ = cmd.Process.Kill() - return nil } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index c4f4530be..90265e5bd 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -4,15 +4,14 @@ import ( "context" "os" "path/filepath" - "regexp" - "runtime" "strings" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/config" ) // TestShellTool_Success verifies successful command execution - func TestShellTool_Success(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { @@ -20,7 +19,6 @@ func TestShellTool_Success(t *testing.T) { } ctx := context.Background() - args := map[string]any{ "command": "echo 'hello world'", } @@ -28,26 +26,22 @@ func TestShellTool_Success(t *testing.T) { result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForUser should contain command output - if !strings.Contains(result.ForUser, "hello world") { t.Errorf("Expected ForUser to contain 'hello world', got: %s", result.ForUser) } // ForLLM should contain full output - if !strings.Contains(result.ForLLM, "hello world") { t.Errorf("Expected ForLLM to contain 'hello world', got: %s", result.ForLLM) } } // TestShellTool_Failure verifies failed command execution - func TestShellTool_Failure(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { @@ -55,7 +49,6 @@ func TestShellTool_Failure(t *testing.T) { } ctx := context.Background() - args := map[string]any{ "command": "ls /nonexistent_directory_12345", } @@ -63,26 +56,22 @@ func TestShellTool_Failure(t *testing.T) { result := tool.Execute(ctx, args) // Failure should be marked as error - if !result.IsError { t.Errorf("Expected error for failed command, got IsError=false") } // ForUser should contain error information - if result.ForUser == "" { t.Errorf("Expected ForUser to contain error info, got empty string") } // ForLLM should contain exit code or error - if !strings.Contains(result.ForLLM, "Exit code") && result.ForUser == "" { t.Errorf("Expected ForLLM to contain exit code or error, got: %s", result.ForLLM) } } // TestShellTool_Timeout verifies command timeout handling - func TestShellTool_Timeout(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { @@ -92,7 +81,6 @@ func TestShellTool_Timeout(t *testing.T) { tool.SetTimeout(100 * time.Millisecond) ctx := context.Background() - args := map[string]any{ "command": "sleep 10", } @@ -100,27 +88,21 @@ func TestShellTool_Timeout(t *testing.T) { result := tool.Execute(ctx, args) // Timeout should be marked as error - if !result.IsError { t.Errorf("Expected error for timeout, got IsError=false") } // Should mention timeout - if !strings.Contains(result.ForLLM, "timed out") && !strings.Contains(result.ForUser, "timed out") { t.Errorf("Expected timeout message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) } } // TestShellTool_WorkingDir verifies custom working directory - func TestShellTool_WorkingDir(t *testing.T) { // Create temp directory - tmpDir := t.TempDir() - testFile := filepath.Join(tmpDir, "test.txt") - os.WriteFile(testFile, []byte("test content"), 0o644) tool, err := NewExecTool("", false) @@ -129,10 +111,8 @@ func TestShellTool_WorkingDir(t *testing.T) { } ctx := context.Background() - args := map[string]any{ - "command": "cat test.txt", - + "command": "cat test.txt", "working_dir": tmpDir, } @@ -148,7 +128,6 @@ func TestShellTool_WorkingDir(t *testing.T) { } // TestShellTool_DangerousCommand verifies safety guard blocks dangerous commands - func TestShellTool_DangerousCommand(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { @@ -156,7 +135,6 @@ func TestShellTool_DangerousCommand(t *testing.T) { } ctx := context.Background() - args := map[string]any{ "command": "rm -rf /", } @@ -164,7 +142,6 @@ func TestShellTool_DangerousCommand(t *testing.T) { result := tool.Execute(ctx, args) // Dangerous command should be blocked - if !result.IsError { t.Errorf("Expected dangerous command to be blocked (IsError=true)") } @@ -174,8 +151,27 @@ func TestShellTool_DangerousCommand(t *testing.T) { } } -// TestShellTool_MissingCommand verifies error handling for missing command +func TestShellTool_DangerousCommand_KillBlocked(t *testing.T) { + tool, err := NewExecTool("", false) + if err != nil { + t.Errorf("unable to configure exec tool: %s", err) + } + ctx := context.Background() + args := map[string]any{ + "command": "kill 12345", + } + + result := tool.Execute(ctx, args) + if !result.IsError { + t.Errorf("Expected kill command to be blocked") + } + if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { + t.Errorf("Expected blocked message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + } +} + +// TestShellTool_MissingCommand verifies error handling for missing command func TestShellTool_MissingCommand(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { @@ -183,20 +179,17 @@ func TestShellTool_MissingCommand(t *testing.T) { } ctx := context.Background() - args := map[string]any{} result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when command is missing") } } // TestShellTool_StderrCapture verifies stderr is captured and included - func TestShellTool_StderrCapture(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { @@ -204,7 +197,6 @@ func TestShellTool_StderrCapture(t *testing.T) { } ctx := context.Background() - args := map[string]any{ "command": "sh -c 'echo stdout; echo stderr >&2'", } @@ -212,18 +204,15 @@ func TestShellTool_StderrCapture(t *testing.T) { result := tool.Execute(ctx, args) // Both stdout and stderr should be in output - if !strings.Contains(result.ForLLM, "stdout") { t.Errorf("Expected stdout in output, got: %s", result.ForLLM) } - if !strings.Contains(result.ForLLM, "stderr") { t.Errorf("Expected stderr in output, got: %s", result.ForLLM) } } // TestShellTool_OutputTruncation verifies long output is truncated - func TestShellTool_OutputTruncation(t *testing.T) { tool, err := NewExecTool("", false) if err != nil { @@ -231,9 +220,7 @@ func TestShellTool_OutputTruncation(t *testing.T) { } ctx := context.Background() - // Generate long output (>10000 chars) - args := map[string]any{ "command": "python3 -c \"print('x' * 20000)\" || echo " + strings.Repeat("x", 20000), } @@ -241,25 +228,19 @@ func TestShellTool_OutputTruncation(t *testing.T) { result := tool.Execute(ctx, args) // Should have truncation message or be truncated - if len(result.ForLLM) > 15000 { t.Errorf("Expected output to be truncated, got length: %d", len(result.ForLLM)) } } // TestShellTool_WorkingDir_OutsideWorkspace verifies that working_dir cannot escape the workspace directly - func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { root := t.TempDir() - workspace := filepath.Join(root, "workspace") - outsideDir := filepath.Join(root, "outside") - if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatalf("failed to create workspace: %v", err) } - if err := os.MkdirAll(outsideDir, 0o755); err != nil { t.Fatalf("failed to create outside dir: %v", err) } @@ -270,45 +251,34 @@ func TestShellTool_WorkingDir_OutsideWorkspace(t *testing.T) { } result := tool.Execute(context.Background(), map[string]any{ - "command": "pwd", - + "command": "pwd", "working_dir": outsideDir, }) if !result.IsError { t.Fatalf("expected working_dir outside workspace to be blocked, got output: %s", result.ForLLM) } - if !strings.Contains(result.ForLLM, "blocked") { t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) } } // TestShellTool_WorkingDir_SymlinkEscape verifies that a symlink inside the workspace - // pointing outside cannot be used as working_dir to escape the sandbox. - func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { root := t.TempDir() - workspace := filepath.Join(root, "workspace") - secretDir := filepath.Join(root, "secret") - if err := os.MkdirAll(workspace, 0o755); err != nil { t.Fatalf("failed to create workspace: %v", err) } - if err := os.MkdirAll(secretDir, 0o755); err != nil { t.Fatalf("failed to create secret dir: %v", err) } - os.WriteFile(filepath.Join(secretDir, "secret.txt"), []byte("top secret"), 0o644) // symlink lives inside the workspace but resolves to secretDir outside it - link := filepath.Join(workspace, "escape") - if err := os.Symlink(secretDir, link); err != nil { t.Skipf("symlinks not supported in this environment: %v", err) } @@ -319,25 +289,100 @@ func TestShellTool_WorkingDir_SymlinkEscape(t *testing.T) { } result := tool.Execute(context.Background(), map[string]any{ - "command": "cat secret.txt", - + "command": "cat secret.txt", "working_dir": link, }) if !result.IsError { t.Fatalf("expected symlink working_dir escape to be blocked, got output: %s", result.ForLLM) } - if !strings.Contains(result.ForLLM, "blocked") { t.Errorf("expected 'blocked' in error, got: %s", result.ForLLM) } } -// TestShellTool_RestrictToWorkspace verifies workspace restriction +// TestShellTool_RemoteChannelBlockedByDefault verifies exec is blocked for remote channels +func TestShellTool_RemoteChannelBlockedByDefault(t *testing.T) { + cfg := &config.Config{} + cfg.Tools.Exec.EnableDenyPatterns = true + cfg.Tools.Exec.AllowRemote = false + tool, err := NewExecToolWithConfig("", false, cfg) + if err != nil { + t.Fatalf("NewExecToolWithConfig() error: %v", err) + } + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{"command": "echo hi"}) + + if !result.IsError { + t.Fatal("expected remote-channel exec to be blocked") + } + if !strings.Contains(result.ForLLM, "restricted to internal channels") { + t.Errorf("expected 'restricted to internal channels' message, got: %s", result.ForLLM) + } +} + +// TestShellTool_InternalChannelAllowed verifies exec is allowed for internal channels +func TestShellTool_InternalChannelAllowed(t *testing.T) { + cfg := &config.Config{} + cfg.Tools.Exec.EnableDenyPatterns = true + cfg.Tools.Exec.AllowRemote = false + + tool, err := NewExecToolWithConfig("", false, cfg) + if err != nil { + t.Fatalf("NewExecToolWithConfig() error: %v", err) + } + ctx := WithToolContext(context.Background(), "cli", "direct") + result := tool.Execute(ctx, map[string]any{"command": "echo hi"}) + + if result.IsError { + t.Fatalf("expected internal channel exec to succeed, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "hi") { + t.Errorf("expected output to contain 'hi', got: %s", result.ForLLM) + } +} + +// TestShellTool_EmptyChannelBlockedWhenNotAllowRemote verifies fail-closed when no channel context +func TestShellTool_EmptyChannelBlockedWhenNotAllowRemote(t *testing.T) { + cfg := &config.Config{} + cfg.Tools.Exec.EnableDenyPatterns = true + cfg.Tools.Exec.AllowRemote = false + + tool, err := NewExecToolWithConfig("", false, cfg) + if err != nil { + t.Fatalf("NewExecToolWithConfig() error: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "command": "echo hi", + }) + + if !result.IsError { + t.Fatal("expected exec with empty channel to be blocked when allowRemote=false") + } +} + +// TestShellTool_AllowRemoteBypassesChannelCheck verifies allowRemote=true permits any channel +func TestShellTool_AllowRemoteBypassesChannelCheck(t *testing.T) { + cfg := &config.Config{} + cfg.Tools.Exec.EnableDenyPatterns = true + cfg.Tools.Exec.AllowRemote = true + + tool, err := NewExecToolWithConfig("", false, cfg) + if err != nil { + t.Fatalf("NewExecToolWithConfig() error: %v", err) + } + ctx := WithToolContext(context.Background(), "telegram", "chat-1") + result := tool.Execute(ctx, map[string]any{"command": "echo hi"}) + + if result.IsError { + t.Fatalf("expected allowRemote=true to permit remote channel, got: %s", result.ForLLM) + } +} + +// TestShellTool_RestrictToWorkspace verifies workspace restriction func TestShellTool_RestrictToWorkspace(t *testing.T) { tmpDir := t.TempDir() - tool, err := NewExecTool(tmpDir, false) if err != nil { t.Errorf("unable to configure exec tool: %s", err) @@ -346,7 +391,6 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) { tool.SetRestrictToWorkspace(true) ctx := context.Background() - args := map[string]any{ "command": "cat ../../etc/passwd", } @@ -354,1035 +398,127 @@ func TestShellTool_RestrictToWorkspace(t *testing.T) { result := tool.Execute(ctx, args) // Path traversal should be blocked - if !result.IsError { t.Errorf("Expected path traversal to be blocked with restrictToWorkspace=true") } if !strings.Contains(result.ForLLM, "blocked") && !strings.Contains(result.ForUser, "blocked") { t.Errorf( - "Expected 'blocked' message for path traversal, got ForLLM: %s, ForUser: %s", - result.ForLLM, - result.ForUser, ) } } -// --- guardCommand unit tests --- - -// TestGuardCommand_RelativePathWithSlashes verifies that relative paths - -// containing slashes (e.g., tests/cold/test.py, projects/terra-py-form) - -// are NOT falsely blocked. This was a regression caused by the old regex - -// matching "/cold/test.py" from "tests/cold/test.py" as an absolute path. - -func TestGuardCommand_RelativePathWithSlashes(t *testing.T) { - workspace := t.TempDir() - - tool, _ := NewExecTool(workspace, true) - - cmds := []string{ - "pytest tests/cold/test_solver.py -v --tb=short", - - "cd projects/terra-py-form && pytest", - - "uv run pytest tests/cold/test_solver.py -v --tb=short", - - "cat src/terra_py_form/cold/parser.py", - - "python src/main.py --config config/dev.json", +// TestShellTool_DevNullAllowed verifies that /dev/null redirections are not blocked (issue #964). +func TestShellTool_DevNullAllowed(t *testing.T) { + tmpDir := t.TempDir() + tool, err := NewExecTool(tmpDir, true) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) } - for _, cmd := range cmds { - result := tool.guardCommand(cmd, workspace) + commands := []string{ + "echo hello 2>/dev/null", + "echo hello >/dev/null", + "echo hello > /dev/null", + "echo hello 2> /dev/null", + "echo hello >/dev/null 2>&1", + "find " + tmpDir + " -name '*.go' 2>/dev/null", + } - if result != "" { - t.Errorf("Relative path should not be blocked: %q → %s", cmd, result) + for _, cmd := range commands { + result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + if result.IsError && strings.Contains(result.ForLLM, "blocked") { + t.Errorf("command should not be blocked: %s\n error: %s", cmd, result.ForLLM) } } } -// TestGuardCommand_VenvBinary verifies that .venv/bin/... paths are allowed - -// (they are relative paths, not absolute). - -func TestGuardCommand_VenvBinary(t *testing.T) { - workspace := t.TempDir() - - tool, _ := NewExecTool(workspace, true) - - cmds := []string{ - ".venv/bin/python -m pytest", - - ".venv/bin/pytest tests/ -v", - - ".venv/bin/pip install -e .", +// TestShellTool_BlockDevices verifies that writes to block devices are blocked (issue #965). +func TestShellTool_BlockDevices(t *testing.T) { + tool, err := NewExecTool("", false) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) } - for _, cmd := range cmds { - result := tool.guardCommand(cmd, workspace) + blocked := []string{ + "echo x > /dev/sda", + "echo x > /dev/hda", + "echo x > /dev/vda", + "echo x > /dev/xvda", + "echo x > /dev/nvme0n1", + "echo x > /dev/mmcblk0", + "echo x > /dev/loop0", + "echo x > /dev/dm-0", + "echo x > /dev/md0", + "echo x > /dev/sr0", + "echo x > /dev/nbd0", + } - if result != "" { - t.Errorf("Venv relative path should not be blocked: %q → %s", cmd, result) + for _, cmd := range blocked { + result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + if !result.IsError { + t.Errorf("expected block device write to be blocked: %s", cmd) } } } -// TestGuardCommand_ExecutableBinaryAllowed verifies that absolute paths - -// to executable files outside the workspace are allowed (system binaries). - -func TestGuardCommand_ExecutableBinaryAllowed(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Unix executable permission test not applicable on Windows") +// TestShellTool_SafePathsInWorkspaceRestriction verifies that safe kernel pseudo-devices +// are allowed even when workspace restriction is active. +func TestShellTool_SafePathsInWorkspaceRestriction(t *testing.T) { + tmpDir := t.TempDir() + tool, err := NewExecTool(tmpDir, true) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) } - workspace := t.TempDir() - - externalDir := t.TempDir() - - // Create a fake executable outside the workspace - - execPath := filepath.Join(externalDir, "mybin") - - os.WriteFile(execPath, []byte("#!/bin/sh\necho ok"), 0o755) - - tool, _ := NewExecTool(workspace, true) - - cmd := execPath + " --help" - - result := tool.guardCommand(cmd, workspace) - - if result != "" { - t.Errorf("Executable binary outside workspace should be allowed: %q → %s", cmd, result) - } -} - -// TestGuardCommand_ExecutableBinaryAllowed_Windows verifies that .exe files - -// outside the workspace are allowed on Windows. - -func TestGuardCommand_ExecutableBinaryAllowed_Windows(t *testing.T) { - if runtime.GOOS != "windows" { - t.Skip("Windows-specific test") + // These reference paths outside workspace but should be allowed via safePaths. + commands := []string{ + "cat /dev/urandom | head -c 16 | od", + "echo test > /dev/null", + "dd if=/dev/zero bs=1 count=1", } - workspace := t.TempDir() - - externalDir := t.TempDir() - - // Create a fake .exe outside the workspace - - execPath := filepath.Join(externalDir, "tool.exe") - - os.WriteFile(execPath, []byte("MZ"), 0o644) - - tool, _ := NewExecTool(workspace, true) - - cmd := execPath + " --version" - - result := tool.guardCommand(cmd, workspace) - - if result != "" { - t.Errorf("Windows .exe outside workspace should be allowed: %q → %s", cmd, result) - } -} - -// TestGuardCommand_NonExecutableOutsideBlocked verifies that non-executable - -// files outside the workspace are blocked (e.g., reading /etc/shadow). - -func TestGuardCommand_NonExecutableOutsideBlocked(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Unix permission test not applicable on Windows") - } - - workspace := t.TempDir() - - externalDir := t.TempDir() - - // Create a regular (non-executable) file outside workspace - - dataFile := filepath.Join(externalDir, "secret.txt") - - os.WriteFile(dataFile, []byte("secret data"), 0o644) - - tool, _ := NewExecTool(workspace, true) - - cmd := "cat " + dataFile - - result := tool.guardCommand(cmd, workspace) - - if result == "" { - t.Errorf("Non-executable file outside workspace should be blocked: %q", cmd) - } - - if !strings.Contains(result, "path outside working dir") { - t.Errorf("Expected 'path outside working dir' message, got: %s", result) - } -} - -// TestGuardCommand_NonExistentAbsolutePathBlocked verifies that absolute - -// paths that don't exist are blocked (could be file creation outside workspace). - -func TestGuardCommand_NonExistentAbsolutePathBlocked(t *testing.T) { - workspace := t.TempDir() - - tool, _ := NewExecTool(workspace, true) - - // Use platform-appropriate absolute path - - var cmd string - - if runtime.GOOS == "windows" { - cmd = "echo hello > C:\\nonexistent_picoclaw_test_output" - } else { - cmd = "echo hello > /tmp/nonexistent_picoclaw_test_output" - } - - result := tool.guardCommand(cmd, workspace) - - if result == "" { - t.Errorf("Non-existent absolute path outside workspace should be blocked: %q", cmd) - } -} - -// TestGuardCommand_FlagEmbeddedPathSkipped verifies that paths embedded in - -// flags (e.g., -I/usr/local/include) are NOT extracted as absolute paths - -// because the token starts with "-", not "/". - -func TestGuardCommand_FlagEmbeddedPathSkipped(t *testing.T) { - workspace := t.TempDir() - - tool, _ := NewExecTool(workspace, true) - - cmds := []string{ - "gcc -I/usr/local/include -L/usr/lib main.c", - - "g++ -std=c++17 -I/opt/include file.cpp", - - "python --prefix=/usr/local script.py", - } - - for _, cmd := range cmds { - result := tool.guardCommand(cmd, workspace) - - if result != "" { - t.Errorf("Flag-embedded path should not be blocked: %q → %s", cmd, result) + for _, cmd := range commands { + result := tool.Execute(context.Background(), map[string]any{"command": cmd}) + if result.IsError && strings.Contains(result.ForLLM, "path outside working dir") { + t.Errorf("safe path should not be blocked by workspace check: %s\n error: %s", cmd, result.ForLLM) } } } -// TestGuardCommand_AbsolutePathInsideWorkspace verifies that absolute paths - -// within the workspace are always allowed. - -func TestGuardCommand_AbsolutePathInsideWorkspace(t *testing.T) { - workspace := t.TempDir() - - tool, _ := NewExecTool(workspace, true) - - innerDir := filepath.Join(workspace, "projects", "myapp") - - os.MkdirAll(innerDir, 0o755) - - cmd := "ls " + innerDir - - result := tool.guardCommand(cmd, workspace) - - if result != "" { - t.Errorf("Absolute path inside workspace should be allowed: %q → %s", cmd, result) - } -} - -// TestGuardCommand_PathTraversal verifies that various path traversal - -// patterns are blocked. - -func TestGuardCommand_PathTraversal(t *testing.T) { - workspace := t.TempDir() - - tool, _ := NewExecTool(workspace, true) - - cmds := []string{ - "cat ../../etc/passwd", - - "cat ../../../etc/shadow", - - "ls projects/../../../../etc", +// TestShellTool_CustomAllowPatterns verifies that custom allow patterns exempt +// commands from deny pattern checks. +func TestShellTool_CustomAllowPatterns(t *testing.T) { + cfg := &config.Config{ + Tools: config.ToolsConfig{ + Exec: config.ExecConfig{ + EnableDenyPatterns: true, + CustomAllowPatterns: []string{`\bgit\s+push\s+origin\b`}, + }, + }, } - for _, cmd := range cmds { - result := tool.guardCommand(cmd, workspace) - - if result == "" { - t.Errorf("Path traversal should be blocked: %q", cmd) - } - - if !strings.Contains(result, "path traversal") { - t.Errorf("Expected 'path traversal' message, got: %s", result) - } - } -} - -// TestGuardCommand_CdWithAbsoluteWorkspacePath verifies that cd to an - -// absolute path within the workspace followed by other commands is allowed. - -func TestGuardCommand_CdWithAbsoluteWorkspacePath(t *testing.T) { - workspace := t.TempDir() - - innerDir := filepath.Join(workspace, "projects", "foo") - - os.MkdirAll(innerDir, 0o755) - - tool, _ := NewExecTool(workspace, true) - - cmd := "cd " + innerDir + " && ls -la" - - result := tool.guardCommand(cmd, workspace) - - if result != "" { - t.Errorf("cd to workspace subdir should be allowed: %q → %s", cmd, result) - } -} - -func TestGuardCommand_AgentCLISlashCommand(t *testing.T) { - workspace := t.TempDir() - - tool, _ := NewExecTool(workspace, true) - - // Agent CLI slash commands (e.g., "/review") are not file paths. - - // They should be allowed because they don't exist on disk. - - cmds := []string{ - `codex exec --yolo "/review skip-git-repo-check"`, - - `claude "/review"`, - - `gemini "/help"`, - } - - for _, cmd := range cmds { - result := tool.guardCommand(cmd, workspace) - - if result != "" { - t.Errorf("Agent CLI slash command should not be blocked: %q → %s", cmd, result) - } - } - - // Non-agent commands with absolute paths should still be blocked. - - if runtime.GOOS != "windows" { - blocked := `cat /etc/hosts` - - result := tool.guardCommand(blocked, workspace) - - if result == "" { - t.Errorf("Non-agent command with absolute path should be blocked: %q", blocked) - } - } -} - -// TestGuardCommand_DenyPattern_IncludesPattern verifies that deny-match - -// error messages include the matched pattern string. - -func TestGuardCommand_DenyPattern_IncludesPattern(t *testing.T) { - workspace := t.TempDir() - - tool, _ := NewExecTool(workspace, true) - - // Also add a custom deny pattern for precise matching. - - tool.denyPatterns = append(tool.denyPatterns, regexp.MustCompile(`\bdangerous_cmd\b`)) - - result := tool.guardCommand("dangerous_cmd --force", workspace) - - if result == "" { - t.Fatal("expected deny pattern to block the command") - } - - if !strings.Contains(result, "deny pattern") { - t.Errorf("expected 'deny pattern' in message, got: %s", result) - } - - if !strings.Contains(result, `\bdangerous_cmd\b`) { - t.Errorf("expected pattern string in message, got: %s", result) - } -} - -// TestGuardCommand_Allowlist_ShowsRules verifies that allowlist violation - -// messages include all configured rules. - -func TestGuardCommand_Allowlist_ShowsRules(t *testing.T) { - workspace := t.TempDir() - - tool, _ := NewExecTool(workspace, true) - - tool.SetAllowRules([]string{"go test", "git"}) - - result := tool.guardCommand("curl http://example.com", workspace) - - if result == "" { - t.Fatal("expected allowlist to block the command") - } - - if !strings.Contains(result, "not in allowlist") { - t.Errorf("expected 'not in allowlist' in message, got: %s", result) - } - - if !strings.Contains(result, "go test") || !strings.Contains(result, "git") { - t.Errorf("expected allowlist rules in message, got: %s", result) - } -} - -// TestGuardCommand_PathOutside_IncludesPath verifies that workspace-escape - -// messages include the offending path token. - -func TestGuardCommand_PathOutside_IncludesPath(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("Unix absolute path test not applicable on Windows") - } - - workspace := t.TempDir() - - externalDir := t.TempDir() - - dataFile := filepath.Join(externalDir, "secret.txt") - - os.WriteFile(dataFile, []byte("secret"), 0o644) - - tool, _ := NewExecTool(workspace, true) - - result := tool.guardCommand("cat "+dataFile, workspace) - - if result == "" { - t.Fatal("expected path outside workspace to be blocked") - } - - if !strings.Contains(result, "path outside working dir") { - t.Errorf("expected 'path outside working dir' in message, got: %s", result) - } - - if !strings.Contains(result, dataFile) { - t.Errorf("expected offending path %q in message, got: %s", dataFile, result) - } -} - -// --- Background process tests --- - -func TestExecTool_Bg_StartAndOutput(t *testing.T) { - tool, _ := NewExecTool("", false) - - defer tool.Shutdown() - - var cmd string - - if runtime.GOOS == "windows" { - cmd = "Write-Output 'hello from bg'; Start-Sleep -Seconds 30" - } else { - cmd = "echo 'hello from bg'; sleep 30" + tool, err := NewExecToolWithConfig("", false, cfg) + if err != nil { + t.Fatalf("unable to configure exec tool: %s", err) } + // "git push origin main" should be allowed by custom allow pattern. result := tool.Execute(context.Background(), map[string]any{ - "command": cmd, - - "background": true, + "command": "git push origin main", }) - - if result.IsError { - t.Fatalf("failed to start bg process: %s", result.ForLLM) + if result.IsError && strings.Contains(result.ForLLM, "blocked") { + t.Errorf("custom allow pattern should exempt 'git push origin main', got: %s", result.ForLLM) } - if !strings.Contains(result.ForLLM, "bg-1") { - t.Errorf("expected bg-1 in result, got: %s", result.ForLLM) - } - - if !strings.Contains(result.ForLLM, "Background process started") { - t.Errorf("expected start message, got: %s", result.ForLLM) - } - - // Get output - - outputResult := tool.Execute(context.Background(), map[string]any{ - "bg_action": "output", - - "bg_id": "bg-1", - }) - - if outputResult.IsError { - t.Fatalf("failed to get output: %s", outputResult.ForLLM) - } - - if !strings.Contains(outputResult.ForLLM, "hello from bg") { - t.Errorf("expected 'hello from bg' in output, got: %s", outputResult.ForLLM) - } - - if !strings.Contains(outputResult.ForLLM, "running") { - t.Errorf("expected 'running' status, got: %s", outputResult.ForLLM) - } -} - -func TestExecTool_Bg_Kill(t *testing.T) { - tool, _ := NewExecTool("", false) - - defer tool.Shutdown() - - var cmd string - - if runtime.GOOS == "windows" { - cmd = "Start-Sleep -Seconds 60" - } else { - cmd = "sleep 60" - } - - result := tool.Execute(context.Background(), map[string]any{ - "command": cmd, - - "background": true, - }) - - if result.IsError { - t.Fatalf("failed to start bg process: %s", result.ForLLM) - } - - // Kill it - - killResult := tool.Execute(context.Background(), map[string]any{ - "bg_action": "kill", - - "bg_id": "bg-1", - }) - - if killResult.IsError { - t.Fatalf("failed to kill: %s", killResult.ForLLM) - } - - if !strings.Contains(killResult.ForLLM, "terminated") { - t.Errorf("expected 'terminated' message, got: %s", killResult.ForLLM) - } - - // Process should no longer be in the map - - procs := tool.BgProcesses() - - if _, ok := procs["bg-1"]; ok { - t.Errorf("expected bg-1 to be removed after kill") - } -} - -func TestExecTool_Bg_ExitedProcess(t *testing.T) { - tool, _ := NewExecTool("", false) - - defer tool.Shutdown() - - var cmd string - - if runtime.GOOS == "windows" { - cmd = "Write-Output 'quick exit'" - } else { - cmd = "echo 'quick exit'" - } - - result := tool.Execute(context.Background(), map[string]any{ - "command": cmd, - - "background": true, - }) - - if result.IsError { - t.Fatalf("failed to start bg process: %s", result.ForLLM) - } - - // Wait for process to exit (initial capture is 3s, so after that it should be done) - - time.Sleep(4 * time.Second) - - // Get output — should show exited - - outputResult := tool.Execute(context.Background(), map[string]any{ - "bg_action": "output", - - "bg_id": "bg-1", - }) - - if outputResult.IsError { - t.Fatalf("failed to get output: %s", outputResult.ForLLM) - } - - if !strings.Contains(outputResult.ForLLM, "exited") { - t.Errorf("expected 'exited' in output, got: %s", outputResult.ForLLM) - } - - if !strings.Contains(outputResult.ForLLM, "quick exit") { - t.Errorf("expected 'quick exit' in output, got: %s", outputResult.ForLLM) - } -} - -func TestExecTool_Bg_InvalidID(t *testing.T) { - tool, _ := NewExecTool("", false) - - defer tool.Shutdown() - - // Output for non-existent ID - - result := tool.Execute(context.Background(), map[string]any{ - "bg_action": "output", - - "bg_id": "bg-999", - }) - - if !result.IsError { - t.Fatalf("expected error for invalid bg_id") - } - - if !strings.Contains(result.ForLLM, "not found") { - t.Errorf("expected 'not found' message, got: %s", result.ForLLM) - } - - // Kill for non-existent ID - + // "git push upstream main" should still be blocked (does not match allow pattern). result = tool.Execute(context.Background(), map[string]any{ - "bg_action": "kill", - - "bg_id": "bg-999", + "command": "git push upstream main", }) - if !result.IsError { - t.Fatalf("expected error for invalid bg_id") - } -} - -func TestExecTool_Bg_InitialOutputCapture(t *testing.T) { - tool, _ := NewExecTool("", false) - - defer tool.Shutdown() - - var cmd string - - if runtime.GOOS == "windows" { - cmd = "Write-Output 'initial line 1'; Write-Output 'initial line 2'; Start-Sleep -Seconds 30" - } else { - cmd = "echo 'initial line 1'; echo 'initial line 2'; sleep 30" - } - - result := tool.Execute(context.Background(), map[string]any{ - "command": cmd, - - "background": true, - }) - - if result.IsError { - t.Fatalf("failed to start bg process: %s", result.ForLLM) - } - - if !strings.Contains(result.ForLLM, "initial line 1") { - t.Errorf("expected 'initial line 1' in initial output, got: %s", result.ForLLM) - } - - if !strings.Contains(result.ForLLM, "initial line 2") { - t.Errorf("expected 'initial line 2' in initial output, got: %s", result.ForLLM) - } -} - -func TestExecTool_Bg_RuntimeStatus(t *testing.T) { - tool, _ := NewExecTool("", false) - - defer tool.Shutdown() - - // No bg processes — should return empty - - if s := tool.RuntimeStatus(); s != "" { - t.Errorf("expected empty runtime status with no bg processes, got: %s", s) - } - - var cmd string - - if runtime.GOOS == "windows" { - cmd = "Start-Sleep -Seconds 30" - } else { - cmd = "sleep 30" - } - - tool.Execute(context.Background(), map[string]any{ - "command": cmd, - - "background": true, - }) - - status := tool.RuntimeStatus() - - if !strings.Contains(status, "Background Processes") { - t.Errorf("expected 'Background Processes' section, got: %s", status) - } - - if !strings.Contains(status, "bg-1") { - t.Errorf("expected 'bg-1' in status, got: %s", status) - } - - if !strings.Contains(status, "running") { - t.Errorf("expected 'running' in status, got: %s", status) - } -} - -func TestExecTool_Bg_Shutdown(t *testing.T) { - tool, _ := NewExecTool("", false) - - var cmd string - - if runtime.GOOS == "windows" { - cmd = "Start-Sleep -Seconds 60" - } else { - cmd = "sleep 60" - } - - tool.Execute(context.Background(), map[string]any{ - "command": cmd, - - "background": true, - }) - - tool.Execute(context.Background(), map[string]any{ - "command": cmd, - - "background": true, - }) - - // Both should be running - - procs := tool.BgProcesses() - - for _, bp := range procs { - if !bp.isRunning() { - t.Errorf("expected process to be running before shutdown") - } - } - - // Shutdown - - tool.Shutdown() - - // All should be done - - procs = tool.BgProcesses() - - for _, bp := range procs { - if bp.isRunning() { - t.Errorf("expected process to be stopped after shutdown") - } - } -} - -func TestRingBuffer(t *testing.T) { - t.Run("Write and String", func(t *testing.T) { - rb := newRingBuffer(100) - - rb.Write([]byte("hello ")) - - rb.Write([]byte("world")) - - if got := rb.String(); got != "hello world" { - t.Errorf("expected 'hello world', got %q", got) - } - }) - - t.Run("Lines", func(t *testing.T) { - rb := newRingBuffer(100) - - rb.Write([]byte("line1\nline2\nline3\nline4\nline5\n")) - - lines := rb.Lines(3) - - if len(lines) != 3 { - t.Fatalf("expected 3 lines, got %d", len(lines)) - } - - if lines[0] != "line3" || lines[1] != "line4" || lines[2] != "line5" { - t.Errorf("unexpected lines: %v", lines) - } - }) - - t.Run("Match", func(t *testing.T) { - rb := newRingBuffer(100) - - rb.Write([]byte("starting...\nServer ready on port 3000\nwaiting...\n")) - - re := regexp.MustCompile(`ready.*port`) - - match := rb.Match(re) - - if match == "" { - t.Fatal("expected match but got empty string") - } - - if !strings.Contains(match, "ready") { - t.Errorf("expected match to contain 'ready', got: %s", match) - } - - // Non-matching pattern - - re2 := regexp.MustCompile(`never_match`) - - match2 := rb.Match(re2) - - if match2 != "" { - t.Errorf("expected no match, got: %s", match2) - } - }) - - t.Run("Overflow", func(t *testing.T) { - rb := newRingBuffer(10) // small buffer - - rb.Write([]byte("1234567890ABCDEF")) - - got := rb.String() - - if len(got) != 10 { - t.Errorf("expected buffer to be 10 bytes, got %d", len(got)) - } - - // Should keep the last 10 bytes - - if got != "7890ABCDEF" { - t.Errorf("expected '7890ABCDEF', got %q", got) - } - }) - - t.Run("Len", func(t *testing.T) { - rb := newRingBuffer(100) - - if rb.Len() != 0 { - t.Errorf("expected 0 length initially") - } - - rb.Write([]byte("hello")) - - if rb.Len() != 5 { - t.Errorf("expected 5, got %d", rb.Len()) - } - }) - - t.Run("Empty Lines", func(t *testing.T) { - rb := newRingBuffer(100) - - lines := rb.Lines(5) - - if lines != nil { - t.Errorf("expected nil for empty buffer, got: %v", lines) - } - }) -} - -func TestExecTool_Bg_RingBufferOverflow(t *testing.T) { - tool, _ := NewExecTool("", false) - - defer tool.Shutdown() - - // Generate output larger than 32KB ring buffer - - var cmd string - - if runtime.GOOS == "windows" { - cmd = "1..2000 | ForEach-Object { Write-Output ('x' * 50) }; Start-Sleep -Seconds 30" - } else { - cmd = "yes 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' | head -n 2000; sleep 30" - } - - result := tool.Execute(context.Background(), map[string]any{ - "command": cmd, - - "background": true, - }) - - if result.IsError { - t.Fatalf("failed to start bg process: %s", result.ForLLM) - } - - // Wait for output to accumulate - - time.Sleep(5 * time.Second) - - // Get output — ring buffer should have truncated old data - - outputResult := tool.Execute(context.Background(), map[string]any{ - "bg_action": "output", - - "bg_id": "bg-1", - }) - - if outputResult.IsError { - t.Fatalf("failed to get output: %s", outputResult.ForLLM) - } - - // The output should contain data but be bounded by the ring buffer size - - procs := tool.BgProcesses() - - bp := procs["bg-1"] - - if bp == nil { - t.Fatal("bg-1 not found") - } - - bufLen := bp.output.Len() - - if bufLen > bgRingBufSize { - t.Errorf("ring buffer exceeded max size: %d > %d", bufLen, bgRingBufSize) - } -} - -// TestIsLocalHost verifies localhost and RFC 1918 detection using net package. - -func TestIsLocalHost(t *testing.T) { - tests := []struct { - host string - - want bool - }{ - // Loopback / localhost - - {"localhost", true}, - - {"LOCALHOST", true}, - - {"127.0.0.1", true}, - - {"127.0.0.2", true}, - - {"::1", true}, - - // RFC 1918 private ranges - - {"10.0.0.1", true}, - - {"10.255.255.255", true}, - - {"172.16.0.1", true}, - - {"172.31.255.255", true}, - - {"192.168.0.1", true}, - - {"192.168.1.100", true}, - - // Public addresses - - {"8.8.8.8", false}, - - {"1.1.1.1", false}, - - {"example.com", false}, - - {"api.github.com", false}, - - // Edge: non-private but routable private-looking address - - {"172.15.255.255", false}, // just below 172.16/12 - - {"172.32.0.0", false}, // just above 172.31/12 - - } - - for _, tt := range tests { - got := isLocalHost(tt.host) - - if got != tt.want { - t.Errorf("isLocalHost(%q) = %v, want %v", tt.host, got, tt.want) - } - } -} - -// TestCheckCurlLocalNet verifies URL-level enforcement for curl/wget commands. - -func TestCheckCurlLocalNet(t *testing.T) { - tests := []struct { - cmd string - - wantErr bool - }{ - // Allowed: localhost and private IPs - - {"curl http://localhost:3000/health", false}, - - {"curl -v http://127.0.0.1:8080/api/status", false}, - - {"wget http://192.168.1.10/file.bin", false}, - - {"curl -X POST http://10.0.0.5:9000/webhook", false}, - - // Blocked: public addresses - - {"curl http://example.com", true}, - - {"wget https://releases.github.com/v1.tar.gz", true}, - - {"curl http://8.8.8.8/data", true}, - - // Allowed: no http URL (e.g. --help, --version — no network access) - - {"curl --help", false}, - - {"curl --version", false}, - - {"wget --help", false}, - } - - for _, tt := range tests { - errMsg := checkCurlLocalNet(tt.cmd) - - gotErr := errMsg != "" - - if gotErr != tt.wantErr { - t.Errorf("checkCurlLocalNet(%q): gotErr=%v wantErr=%v (msg: %q)", - - tt.cmd, gotErr, tt.wantErr, errMsg) - } - } -} - -// TestExecTool_LocalNetOnly verifies curl/wget blocking via SetLocalNetOnly. - -func TestExecTool_LocalNetOnly(t *testing.T) { - tool, _ := NewExecTool("", false) - - tool.SetLocalNetOnly(true) - - tests := []struct { - cmd string - - wantErr bool - }{ - {"curl http://localhost:3000", false}, - - {"curl http://example.com", true}, - - {"echo hello", false}, // non-curl not affected - - } - - ctx := context.Background() - - for _, tt := range tests { - result := tool.Execute(ctx, map[string]any{"command": tt.cmd}) - - if tt.wantErr && !result.IsError { - t.Errorf("cmd %q: expected blocked, but succeeded", tt.cmd) - } - - if !tt.wantErr && result.IsError && strings.Contains(result.ForLLM, "safety guard") { - t.Errorf("cmd %q: expected allowed, but safety guard blocked: %s", tt.cmd, result.ForLLM) - } + t.Errorf("'git push upstream main' should still be blocked by deny pattern") } } diff --git a/pkg/tools/shell_timeout_unix_test.go b/pkg/tools/shell_timeout_unix_test.go index 748a055e8..357e1276e 100644 --- a/pkg/tools/shell_timeout_unix_test.go +++ b/pkg/tools/shell_timeout_unix_test.go @@ -13,33 +13,12 @@ import ( "time" ) -func processRunning(pid int) bool { +func processExists(pid int) bool { if pid <= 0 { return false } - - // kill(0) can return success for zombie processes too, so inspect /proc - // state and treat zombies as not-running for timeout cleanup assertions. err := syscall.Kill(pid, 0) - if err != nil && err != syscall.EPERM { - return false - } - - data, readErr := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat") - if readErr != nil { - return false - } - raw := string(data) - end := strings.LastIndex(raw, ")") - if end == -1 || end+2 >= len(raw) { - return true // best effort fallback - } - fields := strings.Fields(raw[end+2:]) - if len(fields) == 0 { - return true // best effort fallback - } - state := fields[0] - return state != "Z" + return err == nil || err == syscall.EPERM } func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { @@ -47,12 +26,14 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { if err != nil { t.Errorf("unable to configure exec tool: %s", err) } + tool.SetTimeout(500 * time.Millisecond) args := map[string]any{ // Spawn a child process that would outlive the shell unless process-group kill is used. "command": "sleep 60 & echo $! > child.pid; wait", } + result := tool.Execute(context.Background(), args) if !result.IsError { t.Fatalf("expected timeout error, got success: %s", result.ForLLM) @@ -66,6 +47,7 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { if err != nil { t.Fatalf("failed to read child pid file: %v", err) } + childPID, err := strconv.Atoi(strings.TrimSpace(string(data))) if err != nil { t.Fatalf("failed to parse child pid: %v", err) @@ -73,10 +55,11 @@ func TestShellTool_TimeoutKillsChildProcess(t *testing.T) { deadline := time.Now().Add(2 * time.Second) for time.Now().Before(deadline) { - if !processRunning(childPID) { + if !processExists(childPID) { return } time.Sleep(50 * time.Millisecond) } + t.Fatalf("child process %d is still running after timeout", childPID) } diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 89c5dcfe0..71bfe730b 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -16,32 +16,22 @@ import ( ) // InstallSkillTool allows the LLM agent to install skills from registries. - // It shares the same RegistryManager that FindSkillsTool uses, - // so all registries configured in config are available for installation. - type InstallSkillTool struct { registryMgr *skills.RegistryManager - - workspace string - - mu sync.Mutex + workspace string + mu sync.Mutex } // NewInstallSkillTool creates a new InstallSkillTool. - // registryMgr is the shared registry manager (same instance as FindSkillsTool). - // workspace is the root workspace directory; skills install to {workspace}/skills/{slug}/. - func NewInstallSkillTool(registryMgr *skills.RegistryManager, workspace string) *InstallSkillTool { return &InstallSkillTool{ registryMgr: registryMgr, - - workspace: workspace, - - mu: sync.Mutex{}, + workspace: workspace, + mu: sync.Mutex{}, } } @@ -56,210 +46,151 @@ func (t *InstallSkillTool) Description() string { func (t *InstallSkillTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "slug": map[string]any{ - "type": "string", - + "type": "string", "description": "The unique slug of the skill to install (e.g., 'github', 'docker-compose')", }, - "version": map[string]any{ - "type": "string", - + "type": "string", "description": "Specific version to install (optional, defaults to latest)", }, - "registry": map[string]any{ - "type": "string", - + "type": "string", "description": "Registry to install from (required, e.g., 'clawhub')", }, - "force": map[string]any{ - "type": "boolean", - + "type": "boolean", "description": "Force reinstall if skill already exists (default false)", }, }, - "required": []string{"slug", "registry"}, } } func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult { // Install lock to prevent concurrent directory operations. - // Ideally this should be done at a `slug` level, currently, its at a `workspace` level. - t.mu.Lock() - defer t.mu.Unlock() // Validate slug - slug, _ := args["slug"].(string) - if err := utils.ValidateSkillIdentifier(slug); err != nil { return ErrorResult(fmt.Sprintf("invalid slug %q: error: %s", slug, err.Error())) } // Validate registry - registryName, _ := args["registry"].(string) - if err := utils.ValidateSkillIdentifier(registryName); err != nil { return ErrorResult(fmt.Sprintf("invalid registry %q: error: %s", registryName, err.Error())) } version, _ := args["version"].(string) - force, _ := args["force"].(bool) // Check if already installed. - skillsDir := filepath.Join(t.workspace, "skills") - targetDir := filepath.Join(skillsDir, slug) if !force { if _, err := os.Stat(targetDir); err == nil { return ErrorResult( - fmt.Sprintf("skill %q already installed at %s. Use force=true to reinstall.", slug, targetDir), ) } } else { // Force: remove existing if present. - os.RemoveAll(targetDir) } // Resolve which registry to use. - registry := t.registryMgr.GetRegistry(registryName) - if registry == nil { return ErrorResult(fmt.Sprintf("registry %q not found", registryName)) } // Ensure skills directory exists. - if err := os.MkdirAll(skillsDir, 0o755); err != nil { return ErrorResult(fmt.Sprintf("failed to create skills directory: %v", err)) } // Download and install (handles metadata, version resolution, extraction). - result, err := registry.DownloadAndInstall(ctx, slug, version, targetDir) if err != nil { // Clean up partial install. - rmErr := os.RemoveAll(targetDir) - if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", - map[string]any{ - "tool": "install_skill", - + "tool": "install_skill", "target_dir": targetDir, - - "error": rmErr.Error(), + "error": rmErr.Error(), }) } - return ErrorResult(fmt.Sprintf("failed to install %q: %v", slug, err)) } // Moderation: block malware. - if result.IsMalwareBlocked { rmErr := os.RemoveAll(targetDir) - if rmErr != nil { logger.ErrorCF("tool", "Failed to remove partial install", - map[string]any{ - "tool": "install_skill", - + "tool": "install_skill", "target_dir": targetDir, - - "error": rmErr.Error(), + "error": rmErr.Error(), }) } - return ErrorResult(fmt.Sprintf("skill %q is flagged as malicious and cannot be installed", slug)) } // Write origin metadata. - if err := writeOriginMeta(targetDir, registry.Name(), slug, result.Version); err != nil { logger.ErrorCF("tool", "Failed to write origin metadata", - map[string]any{ - "tool": "install_skill", - - "error": err.Error(), - - "target": targetDir, - + "tool": "install_skill", + "error": err.Error(), + "target": targetDir, "registry": registry.Name(), - - "slug": slug, - - "version": result.Version, + "slug": slug, + "version": result.Version, }) - _ = err } // Build result with moderation warning if suspicious. - var output string - if result.IsSuspicious { output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug) } - output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n", - slug, result.Version, registry.Name(), targetDir) if result.Summary != "" { output += fmt.Sprintf("Description: %s\n", result.Summary) } - output += "\nThe skill is now available and can be loaded in the current session." return SilentResult(output) } // originMeta tracks which registry a skill was installed from. - type originMeta struct { - Version int `json:"version"` - - Registry string `json:"registry"` - - Slug string `json:"slug"` - + Version int `json:"version"` + Registry string `json:"registry"` + Slug string `json:"slug"` InstalledVersion string `json:"installed_version"` - - InstalledAt int64 `json:"installed_at"` + InstalledAt int64 `json:"installed_at"` } func writeOriginMeta(targetDir, registryName, slug, version string) error { meta := originMeta{ - Version: 1, - - Registry: registryName, - - Slug: slug, - + Version: 1, + Registry: registryName, + Slug: slug, InstalledVersion: version, - - InstalledAt: time.Now().UnixMilli(), + InstalledAt: time.Now().UnixMilli(), } data, err := json.MarshalIndent(meta, "", " ") @@ -268,6 +199,5 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error { } // Use unified atomic write utility with explicit sync for flash storage reliability. - return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600) } diff --git a/pkg/tools/skills_install_test.go b/pkg/tools/skills_install_test.go index 882e446c6..676fcecc0 100644 --- a/pkg/tools/skills_install_test.go +++ b/pkg/tools/skills_install_test.go @@ -14,29 +14,22 @@ import ( func TestInstallSkillToolName(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - assert.Equal(t, "install_skill", tool.Name()) } func TestInstallSkillToolMissingSlug(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - result := tool.Execute(context.Background(), map[string]any{}) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") } func TestInstallSkillToolEmptySlug(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - result := tool.Execute(context.Background(), map[string]any{ "slug": " ", }) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "identifier is required and must be a non-empty string") } @@ -45,9 +38,7 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) { cases := []string{ "../etc/passwd", - "path/traversal", - "path\\traversal", } @@ -55,85 +46,59 @@ func TestInstallSkillToolUnsafeSlug(t *testing.T) { result := tool.Execute(context.Background(), map[string]any{ "slug": slug, }) - assert.True(t, result.IsError, "slug %q should be rejected", slug) - assert.Contains(t, result.ForLLM, "invalid slug") } } func TestInstallSkillToolAlreadyExists(t *testing.T) { workspace := t.TempDir() - skillDir := filepath.Join(workspace, "skills", "existing-skill") - require.NoError(t, os.MkdirAll(skillDir, 0o755)) tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) - result := tool.Execute(context.Background(), map[string]any{ - "slug": "existing-skill", - + "slug": "existing-skill", "registry": "clawhub", }) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "already installed") } func TestInstallSkillToolRegistryNotFound(t *testing.T) { workspace := t.TempDir() - tool := NewInstallSkillTool(skills.NewRegistryManager(), workspace) - result := tool.Execute(context.Background(), map[string]any{ - "slug": "some-skill", - + "slug": "some-skill", "registry": "nonexistent", }) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "registry") - assert.Contains(t, result.ForLLM, "not found") } func TestInstallSkillToolParameters(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - params := tool.Parameters() props, ok := params["properties"].(map[string]any) - assert.True(t, ok) - assert.Contains(t, props, "slug") - assert.Contains(t, props, "version") - assert.Contains(t, props, "registry") - assert.Contains(t, props, "force") required, ok := params["required"].([]string) - assert.True(t, ok) - assert.Contains(t, required, "slug") - assert.Contains(t, required, "registry") } func TestInstallSkillToolMissingRegistry(t *testing.T) { tool := NewInstallSkillTool(skills.NewRegistryManager(), t.TempDir()) - result := tool.Execute(context.Background(), map[string]any{ "slug": "some-skill", }) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "invalid registry") } diff --git a/pkg/tools/skills_search.go b/pkg/tools/skills_search.go index 48baff7b6..2b6cffd38 100644 --- a/pkg/tools/skills_search.go +++ b/pkg/tools/skills_search.go @@ -9,24 +9,18 @@ import ( ) // FindSkillsTool allows the LLM agent to search for installable skills from registries. - type FindSkillsTool struct { registryMgr *skills.RegistryManager - - cache *skills.SearchCache + cache *skills.SearchCache } // NewFindSkillsTool creates a new FindSkillsTool. - // registryMgr is the shared registry manager (built from config in createToolRegistry). - // cache is the search cache for deduplicating similar queries. - func NewFindSkillsTool(registryMgr *skills.RegistryManager, cache *skills.SearchCache) *FindSkillsTool { return &FindSkillsTool{ registryMgr: registryMgr, - - cache: cache, + cache: cache, } } @@ -41,50 +35,38 @@ func (t *FindSkillsTool) Description() string { func (t *FindSkillsTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "query": map[string]any{ - "type": "string", - + "type": "string", "description": "Search query describing the desired skill capability (e.g., 'github integration', 'database management')", }, - "limit": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Maximum number of results to return (1-20, default 5)", - - "minimum": 1.0, - - "maximum": 20.0, + "minimum": 1.0, + "maximum": 20.0, }, }, - "required": []string{"query"}, } } func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) - query = strings.ToLower(strings.TrimSpace(query)) - if !ok || query == "" { return ErrorResult("query is required and must be a non-empty string") } limit := 5 - if l, ok := args["limit"].(float64); ok { li := int(l) - if li >= 1 && li <= 20 { limit = li } } // Check cache first. - if t.cache != nil { if cached, hit := t.cache.Get(query); hit { return SilentResult(formatSearchResults(query, cached, true)) @@ -92,14 +74,12 @@ func (t *FindSkillsTool) Execute(ctx context.Context, args map[string]any) *Tool } // Search all registries. - results, err := t.registryMgr.SearchAll(ctx, query, limit) if err != nil { return ErrorResult(fmt.Sprintf("skill search failed: %v", err)) } // Cache the results. - if t.cache != nil && len(results) > 0 { t.cache.Put(query, results) } @@ -113,36 +93,27 @@ func formatSearchResults(query string, results []skills.SearchResult, cached boo } var sb strings.Builder - source := "" - if cached { source = " (cached)" } - sb.WriteString(fmt.Sprintf("Found %d skills for %q%s:\n\n", len(results), query, source)) for i, r := range results { sb.WriteString(fmt.Sprintf("%d. **%s**", i+1, r.Slug)) - if r.Version != "" { sb.WriteString(fmt.Sprintf(" v%s", r.Version)) } - sb.WriteString(fmt.Sprintf(" (score: %.3f, registry: %s)\n", r.Score, r.RegistryName)) - if r.DisplayName != "" && r.DisplayName != r.Slug { sb.WriteString(fmt.Sprintf(" Name: %s\n", r.DisplayName)) } - if r.Summary != "" { sb.WriteString(fmt.Sprintf(" %s\n", r.Summary)) } - sb.WriteString("\n") } sb.WriteString("Use install_skill with the slug to install a skill.") - return sb.String() } diff --git a/pkg/tools/skills_search_test.go b/pkg/tools/skills_search_test.go index cb6a0e104..0e5387cf5 100644 --- a/pkg/tools/skills_search_test.go +++ b/pkg/tools/skills_search_test.go @@ -11,110 +11,80 @@ import ( func TestFindSkillsToolName(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) - assert.Equal(t, "find_skills", tool.Name()) } func TestFindSkillsToolMissingQuery(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) - result := tool.Execute(context.Background(), map[string]any{}) - assert.True(t, result.IsError) - assert.Contains(t, result.ForLLM, "query is required") } func TestFindSkillsToolEmptyQuery(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) - result := tool.Execute(context.Background(), map[string]any{ "query": " ", }) - assert.True(t, result.IsError) } func TestFindSkillsToolCacheHit(t *testing.T) { cache := skills.NewSearchCache(10, 5*60*1000*1000*1000) // 5 min - cache.Put("github", []skills.SearchResult{ {Slug: "github", Score: 0.9, RegistryName: "clawhub"}, }) tool := NewFindSkillsTool(skills.NewRegistryManager(), cache) - result := tool.Execute(context.Background(), map[string]any{ "query": "github", }) assert.False(t, result.IsError) - assert.Contains(t, result.ForLLM, "github") - assert.Contains(t, result.ForLLM, "cached") } func TestFindSkillsToolParameters(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) - params := tool.Parameters() props, ok := params["properties"].(map[string]any) - assert.True(t, ok) - assert.Contains(t, props, "query") - assert.Contains(t, props, "limit") required, ok := params["required"].([]string) - assert.True(t, ok) - assert.Contains(t, required, "query") } func TestFindSkillsToolDescription(t *testing.T) { tool := NewFindSkillsTool(skills.NewRegistryManager(), nil) - assert.NotEmpty(t, tool.Description()) - assert.Contains(t, tool.Description(), "skill") } func TestFormatSearchResultsEmpty(t *testing.T) { result := formatSearchResults("test query", nil, false) - assert.Contains(t, result, "No skills found") } func TestFormatSearchResultsWithData(t *testing.T) { results := []skills.SearchResult{ { - Slug: "github", - - Score: 0.95, - - DisplayName: "GitHub", - - Summary: "GitHub API integration", - - Version: "1.0.0", - + Slug: "github", + Score: 0.95, + DisplayName: "GitHub", + Summary: "GitHub API integration", + Version: "1.0.0", RegistryName: "clawhub", }, } - output := formatSearchResults("github", results, false) - assert.Contains(t, output, "github") - assert.Contains(t, output, "v1.0.0") - assert.Contains(t, output, "0.950") - assert.Contains(t, output, "clawhub") - assert.Contains(t, output, "install_skill") } diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index af19ca86d..f0a46fce8 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -18,6 +18,9 @@ type SpawnTool struct { callback AsyncCallback // For async completion notification } +// Compile-time check: SpawnTool implements AsyncExecutor. +var _ AsyncExecutor = (*SpawnTool)(nil) + func NewSpawnTool(manager *SubagentManager) *SpawnTool { return &SpawnTool{ manager: manager, @@ -45,20 +48,17 @@ func (t *SpawnTool) Description() string { func (t *SpawnTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "task": map[string]any{ "type": "string", "description": "The task for subagent to complete", }, - "label": map[string]any{ "type": "string", "description": "Optional short label for the task (for display)", }, - "agent_id": map[string]any{ "type": "string", @@ -73,7 +73,6 @@ func (t *SpawnTool) Parameters() map[string]any { "description": "Optional capability tier: scout (explore), analyst (analyze), coder (code), worker (build), coordinator (orchestrate)", }, }, - "required": []string{"task"}, } } @@ -89,8 +88,17 @@ func (t *SpawnTool) SetAllowlistChecker(check func(targetAgentID string) bool) { } func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResult { - task, ok := args["task"].(string) + return t.execute(ctx, args, t.callback) +} +// ExecuteAsync implements AsyncExecutor. The callback is passed through to the +// subagent manager as a call parameter — never stored on the SpawnTool instance. +func (t *SpawnTool) ExecuteAsync(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { + return t.execute(ctx, args, cb) +} + +func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCallback) *ToolResult { + task, ok := args["task"].(string) if !ok || strings.TrimSpace(task) == "" { return ErrorResult( @@ -101,7 +109,6 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul } label, _ := args["label"].(string) - agentID, _ := args["agent_id"].(string) preset, _ := args["preset"].(string) @@ -135,12 +142,11 @@ func (t *SpawnTool) Execute(ctx context.Context, args map[string]any) *ToolResul // Pass callback to manager for async completion notification - result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, preset, t.callback) + result, err := t.manager.Spawn(ctx, task, label, agentID, t.originChannel, t.originChatID, preset, cb) if err != nil { return ErrorResult(fmt.Sprintf("failed to spawn subagent: %v", err)) } // Return AsyncResult since the task runs in background - return AsyncResult(result) } diff --git a/pkg/tools/spawn_test.go b/pkg/tools/spawn_test.go index 35eb2c0a9..8e1bf17fd 100644 --- a/pkg/tools/spawn_test.go +++ b/pkg/tools/spawn_test.go @@ -8,43 +8,33 @@ import ( func TestSpawnTool_Execute_EmptyTask(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) - tool := NewSpawnTool(manager) ctx := context.Background() tests := []struct { name string - args map[string]any }{ {"empty string", map[string]any{"task": ""}}, - {"whitespace only", map[string]any{"task": " "}}, - {"tabs and newlines", map[string]any{"task": "\t\n "}}, - {"missing task key", map[string]any{"label": "test"}}, - {"wrong type", map[string]any{"task": 123}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := tool.Execute(ctx, tt.args) - if result == nil { t.Fatal("Result should not be nil") } - if !result.IsError { t.Error("Expected error for invalid task parameter") } - - if !strings.Contains(result.ForLLM, `"task"`) { - t.Errorf("Error message should mention '\"task\"', got: %s", result.ForLLM) + if !strings.Contains(result.ForLLM, `Required parameter "task"`) { + t.Errorf("Error message should mention required task param, got: %s", result.ForLLM) } }) } @@ -52,29 +42,22 @@ func TestSpawnTool_Execute_EmptyTask(t *testing.T) { func TestSpawnTool_Execute_ValidTask(t *testing.T) { provider := &MockLLMProvider{} - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) - tool := NewSpawnTool(manager) ctx := context.Background() - args := map[string]any{ - "task": "Write a haiku about coding", - + "task": "Write a haiku about coding", "label": "haiku-task", } result := tool.Execute(ctx, args) - if result == nil { t.Fatal("Result should not be nil") } - if result.IsError { t.Errorf("Expected success for valid task, got error: %s", result.ForLLM) } - if !result.Async { t.Error("SpawnTool should return async result") } @@ -84,16 +67,13 @@ func TestSpawnTool_Execute_NilManager(t *testing.T) { tool := NewSpawnTool(nil) ctx := context.Background() - args := map[string]any{"task": "test task"} result := tool.Execute(ctx, args) - if !result.IsError { t.Error("Expected error for nil manager") } - - if !strings.Contains(result.ForLLM, "spawn tool is not available") { - t.Errorf("Error message should mention spawn tool not available, got: %s", result.ForLLM) + if !strings.Contains(result.ForLLM, "not available") { + t.Errorf("Error message should mention 'not available', got: %s", result.ForLLM) } } diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go index b8c9b3d74..0ca17e84f 100644 --- a/pkg/tools/spi.go +++ b/pkg/tools/spi.go @@ -10,7 +10,6 @@ import ( ) // SPITool provides SPI bus interaction for high-speed peripheral communication. - type SPITool struct{} func NewSPITool() *SPITool { @@ -28,61 +27,42 @@ func (t *SPITool) Description() string { func (t *SPITool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "action": map[string]any{ - "type": "string", - - "enum": []string{"list", "transfer", "read"}, - + "type": "string", + "enum": []string{"list", "transfer", "read"}, "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)", }, - "device": map[string]any{ - "type": "string", - + "type": "string", "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.", }, - "speed": map[string]any{ - "type": "integer", - + "type": "integer", "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).", }, - "mode": map[string]any{ - "type": "integer", - + "type": "integer", "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.", }, - "bits": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Bits per word. Default: 8.", }, - "data": map[string]any{ - "type": "array", - - "items": map[string]any{"type": "integer"}, - + "type": "array", + "items": map[string]any{"type": "integer"}, "description": "Bytes to send (0-255 each). Required for transfer action.", }, - "length": map[string]any{ - "type": "integer", - + "type": "integer", "description": "Number of bytes to read (1-4096). Required for read action.", }, - "confirm": map[string]any{ - "type": "boolean", - + "type": "boolean", "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.", }, }, - "required": []string{"action"}, } } @@ -93,32 +73,23 @@ func (t *SPITool) Execute(ctx context.Context, args map[string]any) *ToolResult } action, ok := args["action"].(string) - if !ok { return ErrorResult("action is required") } switch action { case "list": - return t.list() - case "transfer": - return t.transfer(args) - case "read": - return t.readDevice(args) - default: - return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action)) } } // list finds available SPI devices by globbing /dev/spidev* - func (t *SPITool) list() *ToolResult { matches, err := filepath.Glob("/dev/spidev*") if err != nil { @@ -132,15 +103,12 @@ func (t *SPITool) list() *ToolResult { } type devInfo struct { - Path string `json:"path"` - + Path string `json:"path"` Device string `json:"device"` } devices := make([]devInfo, 0, len(matches)) - re := regexp.MustCompile(`/dev/spidev(\d+\.\d+)`) - for _, m := range matches { if sub := re.FindStringSubmatch(m); sub != nil { devices = append(devices, devInfo{Path: m, Device: sub[1]}) @@ -148,58 +116,45 @@ func (t *SPITool) list() *ToolResult { } result, _ := json.MarshalIndent(devices, "", " ") - return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result))) } // Helper function for SPI operations (used by platform-specific implementations) // parseSPIArgs extracts and validates common SPI parameters - // - //nolint:unused // Used by spi_linux.go - func parseSPIArgs(args map[string]any) (device string, speed uint32, mode uint8, bits uint8, errMsg string) { dev, ok := args["device"].(string) - if !ok || dev == "" { return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)" } - matched, _ := regexp.MatchString(`^\d+\.\d+$`, dev) - if !matched { return "", 0, 0, 0, "invalid device identifier: must be in format \"X.Y\" (e.g. \"2.0\")" } speed = 1000000 // default 1 MHz - if s, ok := args["speed"].(float64); ok { if s < 1 || s > 125000000 { return "", 0, 0, 0, "speed must be between 1 Hz and 125 MHz" } - speed = uint32(s) } mode = 0 - if m, ok := args["mode"].(float64); ok { if int(m) < 0 || int(m) > 3 { return "", 0, 0, 0, "mode must be 0-3" } - mode = uint8(m) } bits = 8 - if b, ok := args["bits"].(float64); ok { if int(b) < 1 || int(b) > 32 { return "", 0, 0, 0, "bits must be between 1 and 32" } - bits = uint8(b) } diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go index 450407d0f..9def73662 100644 --- a/pkg/tools/spi_linux.go +++ b/pkg/tools/spi_linux.go @@ -34,10 +34,8 @@ type spiTransfer struct { pad uint8 } -// configureSPI opens an SPI device and sets mode, bits per word, and speed. -func configureSPI( - devPath string, mode uint8, bits uint8, speed uint32, -) (int, *ToolResult) { +// configureSPI opens an SPI device and sets mode, bits per word, and speed +func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) { fd, err := syscall.Open(devPath, syscall.O_RDWR, 0) if err != nil { return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err)) @@ -67,13 +65,12 @@ func configureSPI( return fd, nil } -// transfer performs a full-duplex SPI transfer. +// transfer performs a full-duplex SPI transfer func (t *SPITool) transfer(args map[string]any) *ToolResult { confirm, _ := args["confirm"].(bool) if !confirm { return ErrorResult( - "transfer operations require confirm: true." + - " Please confirm with the user before sending data to SPI devices.", + "transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.", ) } @@ -111,6 +108,7 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult { defer syscall.Close(fd) rxBuf := make([]byte, len(txBuf)) + xfer := spiTransfer{ txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), @@ -140,11 +138,10 @@ func (t *SPITool) transfer(args map[string]any) *ToolResult { "received": intBytes, "hex": hexBytes, }, "", " ") - return SilentResult(string(result)) } -// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed). +// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed) func (t *SPITool) readDevice(args map[string]any) *ToolResult { dev, speed, mode, bits, errMsg := parseSPIArgs(args) if errMsg != "" { @@ -168,6 +165,7 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult { txBuf := make([]byte, length) // zeros rxBuf := make([]byte, length) + xfer := spiTransfer{ txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))), rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))), @@ -196,6 +194,5 @@ func (t *SPITool) readDevice(args map[string]any) *ToolResult { "hex": hexBytes, "length": len(rxBuf), }, "", " ") - return SilentResult(string(result)) } diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 1926e2eb7..09b3a11ab 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -174,7 +174,6 @@ type SubagentManager struct { func NewSubagentManager( provider providers.LLMProvider, - defaultModel, workspace string, bus *bus.MessageBus, @@ -211,30 +210,20 @@ func NewSubagentManager( } // SetLLMOptions sets max tokens and temperature for subagent LLM calls. - func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() - defer sm.mu.Unlock() - sm.maxTokens = maxTokens - sm.hasMaxTokens = true - sm.temperature = temperature - sm.hasTemperature = true } // SetTools sets the tool registry for subagent execution. - // If not set, subagent will have access to the provided tools. - func (sm *SubagentManager) SetTools(tools *ToolRegistry) { sm.mu.Lock() - defer sm.mu.Unlock() - sm.tools = tools } @@ -251,12 +240,9 @@ func (sm *SubagentManager) SetSessionRecorder(r SessionRecorder, conductorSessio } // RegisterTool registers a tool for subagent execution. - func (sm *SubagentManager) RegisterTool(tool Tool) { sm.mu.Lock() - defer sm.mu.Unlock() - sm.tools.Register(tool) } @@ -268,11 +254,9 @@ func (sm *SubagentManager) Spawn( callback AsyncCallback, ) (string, error) { sm.mu.Lock() - defer sm.mu.Unlock() taskID := fmt.Sprintf("subagent-%d", sm.nextID) - sm.nextID++ subagentTask := &SubagentTask{ @@ -338,7 +322,6 @@ func (sm *SubagentManager) Spawn( if label != "" { return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil } - return fmt.Sprintf("Spawned subagent for task: %s", task), nil } @@ -447,9 +430,7 @@ func (sm *SubagentManager) finishTask( callback AsyncCallback, ) { sm.mu.Lock() - var result *ToolResult - defer func() { sm.mu.Unlock() @@ -460,14 +441,12 @@ func (sm *SubagentManager) finishTask( if err != nil { task.Status = "failed" - task.Result = fmt.Sprintf("Error: %v", err) gcReason := "failed" if ctx.Err() != nil { task.Status = "canceled" - task.Result = "Task canceled during execution" gcReason = "canceled" @@ -492,7 +471,6 @@ func (sm *SubagentManager) finishTask( } } else { task.Status = "completed" - task.Result = loopResult.Content task.CompletedAt = time.Now().UnixMilli() @@ -523,14 +501,12 @@ func (sm *SubagentManager) finishTask( "Subagent '%s' completed (iterations: %d, tool calls: %d): %s", task.Label, - loopResult.Iterations, loopResult.ToolCalls, loopResult.Content, ), - ForUser: loopResult.Content, } } @@ -783,7 +759,7 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string, // Register read_file and list_dir with restrict=true if config.AllowedTools["read_file"] { - registry.Register(NewReadFileTool(readRoot, true)) + registry.Register(NewReadFileTool(readRoot, true, 0)) } if config.AllowedTools["list_dir"] { @@ -854,7 +830,9 @@ func (sm *SubagentManager) buildPresetRegistry(preset Preset, writeRoot string, } if config.AllowedTools["web_fetch"] { - registry.Register(NewWebFetchTool(50000)) + if fetchTool, err := NewWebFetchTool(50000); err == nil { + registry.Register(fetchTool) + } } // Register message tool (always available) @@ -1006,34 +984,25 @@ func (sm *SubagentManager) CancelTask(taskID string) { func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) { sm.mu.RLock() - defer sm.mu.RUnlock() - task, ok := sm.tasks[taskID] - return task, ok } func (sm *SubagentManager) ListTasks() []*SubagentTask { sm.mu.RLock() - defer sm.mu.RUnlock() tasks := make([]*SubagentTask, 0, len(sm.tasks)) - for _, task := range sm.tasks { tasks = append(tasks, task) } - return tasks } // SubagentTool executes a subagent task synchronously and returns the result. - // Unlike SpawnTool which runs tasks asynchronously, SubagentTool waits for completion - // and returns the result directly in the ToolResult. - type SubagentTool struct { manager *SubagentManager @@ -1063,21 +1032,18 @@ func (t *SubagentTool) Description() string { func (t *SubagentTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "task": map[string]any{ "type": "string", "description": "The task for subagent to complete", }, - "label": map[string]any{ "type": "string", "description": "Optional short label for the task (for display)", }, }, - "required": []string{"task"}, } } @@ -1090,7 +1056,6 @@ func (t *SubagentTool) SetContext(channel, chatID string) { func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolResult { task, ok := args["task"].(string) - if !ok { return ErrorResult( @@ -1108,14 +1073,12 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } // Build messages for subagent - messages := []providers.Message{ { Role: "system", Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.", }, - { Role: "user", @@ -1124,34 +1087,22 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } // Use RunToolLoop to execute with tools (same as async SpawnTool) - sm := t.manager - sm.mu.RLock() - tools := sm.tools - maxIter := sm.maxIterations - maxTokens := sm.maxTokens - temperature := sm.temperature - hasMaxTokens := sm.hasMaxTokens - hasTemperature := sm.hasTemperature - sm.mu.RUnlock() var llmOptions map[string]any - if hasMaxTokens || hasTemperature { llmOptions = map[string]any{} - if hasMaxTokens { llmOptions["max_tokens"] = maxTokens } - if hasTemperature { llmOptions["temperature"] = temperature } @@ -1173,19 +1124,14 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe } // ForUser: Brief summary for user (truncated if too long) - userContent := loopResult.Content - maxUserLen := 500 - if len(userContent) > maxUserLen { userContent = userContent[:maxUserLen] + "..." } // ForLLM: Full execution details - labelStr := label - if labelStr == "" { labelStr = "(unnamed)" } diff --git a/pkg/tools/subagent_tool_ext_test.go b/pkg/tools/subagent_tool_ext_test.go new file mode 100644 index 000000000..59e4cc01a --- /dev/null +++ b/pkg/tools/subagent_tool_ext_test.go @@ -0,0 +1,102 @@ +package tools + +import ( + "context" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/orch" +) + +func TestSubagentTool_SetContext(t *testing.T) { + provider := &MockLLMProvider{} + + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) + + tool := NewSubagentTool(manager) + + tool.SetContext("test-channel", "test-chat") +} + +func TestFormatToolStats(t *testing.T) { + tests := []struct { + name string + + stats map[string]int + + want string + }{ + {"empty", map[string]int{}, ""}, + + {"single", map[string]int{"exec": 3}, "exec:3"}, + + { + "multiple sorted", + + map[string]int{"read_file": 5, "exec": 3, "write_file": 1}, + + "exec:3,read_file:5,write_file:1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatToolStats(tt.stats) + + if got != tt.want { + t.Errorf("formatToolStats(%v) = %q, want %q", tt.stats, got, tt.want) + } + }) + } +} + +func TestSubagentManager_Spawn_SetsMetadata(t *testing.T) { + provider := &MockLLMProvider{} + + msgBus := bus.NewMessageBus() + + mgr := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) + + _, err := mgr.Spawn( + + context.Background(), + + "say hello", "meta-test", "", "cli", "direct", "", + + nil, + ) + if err != nil { + t.Fatalf("Spawn() error: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + + defer cancel() + + received, ok := msgBus.ConsumeInbound(ctx) + + if !ok { + t.Fatal("timed out waiting for bus message") + } + + if received.Channel != "system" { + t.Fatalf("expected channel 'system', got %q", received.Channel) + } + + if received.Metadata == nil { + t.Fatal("Metadata should not be nil") + } + + if received.Metadata["iterations"] != "1" { + t.Errorf("iterations = %q, want %q", received.Metadata["iterations"], "1") + } + + if received.Metadata["tool_calls"] != "0" { + t.Errorf("tool_calls = %q, want %q", received.Metadata["tool_calls"], "0") + } + + if received.Metadata["duration_ms"] == "" { + t.Error("duration_ms should be present") + } +} diff --git a/pkg/tools/subagent_tool_test.go b/pkg/tools/subagent_tool_test.go index f9e1f988a..2c5e91545 100644 --- a/pkg/tools/subagent_tool_test.go +++ b/pkg/tools/subagent_tool_test.go @@ -4,34 +4,24 @@ import ( "context" "strings" "testing" - "time" - "github.com/sipeed/picoclaw/pkg/bus" - "github.com/sipeed/picoclaw/pkg/orch" "github.com/sipeed/picoclaw/pkg/providers" ) // MockLLMProvider is a test implementation of LLMProvider - type MockLLMProvider struct { lastOptions map[string]any } func (m *MockLLMProvider) Chat( ctx context.Context, - messages []providers.Message, - tools []providers.ToolDefinition, - model string, - options map[string]any, ) (*providers.LLMResponse, error) { m.lastOptions = options - // Find the last user message to generate a response - for i := len(messages) - 1; i >= 0; i-- { if messages[i].Role == "user" { return &providers.LLMResponse{ @@ -39,7 +29,6 @@ func (m *MockLLMProvider) Chat( }, nil } } - return &providers.LLMResponse{Content: "No task provided"}, nil } @@ -57,19 +46,12 @@ func (m *MockLLMProvider) GetContextWindow() int { func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { provider := &MockLLMProvider{} - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) - + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) manager.SetLLMOptions(2048, 0.6) - tool := NewSubagentTool(manager) - tool.SetContext("cli", "direct") - - ctx := context.Background() - + ctx := WithToolContext(context.Background(), "cli", "direct") args := map[string]any{"task": "Do something"} - result := tool.Execute(ctx, args) if result == nil || result.IsError { @@ -79,23 +61,18 @@ func TestSubagentManager_SetLLMOptions_AppliesToRunToolLoop(t *testing.T) { if provider.lastOptions == nil { t.Fatal("Expected LLM options to be passed, got nil") } - if provider.lastOptions["max_tokens"] != 2048 { t.Fatalf("max_tokens = %v, want %d", provider.lastOptions["max_tokens"], 2048) } - if provider.lastOptions["temperature"] != 0.6 { t.Fatalf("temperature = %v, want %v", provider.lastOptions["temperature"], 0.6) } } // TestSubagentTool_Name verifies tool name - func TestSubagentTool_Name(t *testing.T) { provider := &MockLLMProvider{} - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) - + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) tool := NewSubagentTool(manager) if tool.Name() != "subagent" { @@ -104,198 +81,131 @@ func TestSubagentTool_Name(t *testing.T) { } // TestSubagentTool_Description verifies tool description - func TestSubagentTool_Description(t *testing.T) { provider := &MockLLMProvider{} - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) - + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) tool := NewSubagentTool(manager) desc := tool.Description() - if desc == "" { t.Error("Description should not be empty") } - - if !strings.Contains(desc, "BLOCK") { - t.Errorf("Description should mention 'BLOCK', got: %s", desc) - } - - if !strings.Contains(desc, "spawn") { - t.Errorf("Description should contrast with spawn, got: %s", desc) + if !strings.Contains(desc, "subagent") { + t.Errorf("Description should mention 'subagent', got: %s", desc) } } // TestSubagentTool_Parameters verifies tool parameters schema - func TestSubagentTool_Parameters(t *testing.T) { provider := &MockLLMProvider{} - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) - + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) tool := NewSubagentTool(manager) params := tool.Parameters() - if params == nil { t.Error("Parameters should not be nil") } // Check type - if params["type"] != "object" { t.Errorf("Expected type 'object', got: %v", params["type"]) } // Check properties - props, ok := params["properties"].(map[string]any) - if !ok { t.Fatal("Properties should be a map") } // Verify task parameter - task, ok := props["task"].(map[string]any) - if !ok { t.Fatal("Task parameter should exist") } - if task["type"] != "string" { t.Errorf("Task type should be 'string', got: %v", task["type"]) } // Verify label parameter - label, ok := props["label"].(map[string]any) - if !ok { t.Fatal("Label parameter should exist") } - if label["type"] != "string" { t.Errorf("Label type should be 'string', got: %v", label["type"]) } // Check required fields - required, ok := params["required"].([]string) - if !ok { t.Fatal("Required should be a string array") } - if len(required) != 1 || required[0] != "task" { t.Errorf("Required should be ['task'], got: %v", required) } } -// TestSubagentTool_SetContext verifies context setting - -func TestSubagentTool_SetContext(t *testing.T) { - provider := &MockLLMProvider{} - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) - - tool := NewSubagentTool(manager) - - tool.SetContext("test-channel", "test-chat") - - // Verify context is set (we can't directly access private fields, - - // but we can verify it doesn't crash) - - // The actual context usage is tested in Execute tests -} - // TestSubagentTool_Execute_Success tests successful execution - func TestSubagentTool_Execute_Success(t *testing.T) { provider := &MockLLMProvider{} - - msgBus := bus.NewMessageBus() - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) - + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) tool := NewSubagentTool(manager) - tool.SetContext("telegram", "chat-123") - - ctx := context.Background() - + ctx := WithToolContext(context.Background(), "telegram", "chat-123") args := map[string]any{ - "task": "Write a haiku about coding", - + "task": "Write a haiku about coding", "label": "haiku-task", } result := tool.Execute(ctx, args) // Verify basic ToolResult structure - if result == nil { t.Fatal("Result should not be nil") } // Verify no error - if result.IsError { t.Errorf("Expected success, got error: %s", result.ForLLM) } // Verify not async - if result.Async { t.Error("SubagentTool should be synchronous, not async") } // Verify not silent - if result.Silent { t.Error("SubagentTool should not be silent") } // Verify ForUser contains brief summary (not empty) - if result.ForUser == "" { t.Error("ForUser should contain result summary") } - if !strings.Contains(result.ForUser, "Task completed") { t.Errorf("ForUser should contain task completion, got: %s", result.ForUser) } // Verify ForLLM contains full details - if result.ForLLM == "" { t.Error("ForLLM should contain full details") } - if !strings.Contains(result.ForLLM, "haiku-task") { t.Errorf("ForLLM should contain label 'haiku-task', got: %s", result.ForLLM) } - if !strings.Contains(result.ForLLM, "Task completed:") { t.Errorf("ForLLM should contain task result, got: %s", result.ForLLM) } } // TestSubagentTool_Execute_NoLabel tests execution without label - func TestSubagentTool_Execute_NoLabel(t *testing.T) { provider := &MockLLMProvider{} - - msgBus := bus.NewMessageBus() - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) - + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) tool := NewSubagentTool(manager) ctx := context.Background() - args := map[string]any{ "task": "Test task without label", } @@ -307,23 +217,18 @@ func TestSubagentTool_Execute_NoLabel(t *testing.T) { } // ForLLM should show (unnamed) for missing label - if !strings.Contains(result.ForLLM, "(unnamed)") { t.Errorf("ForLLM should show '(unnamed)' for missing label, got: %s", result.ForLLM) } } // TestSubagentTool_Execute_MissingTask tests error handling for missing task - func TestSubagentTool_Execute_MissingTask(t *testing.T) { provider := &MockLLMProvider{} - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, orch.Noop, WebSearchToolOptions{}) - + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) tool := NewSubagentTool(manager) ctx := context.Background() - args := map[string]any{ "label": "test", } @@ -331,35 +236,26 @@ func TestSubagentTool_Execute_MissingTask(t *testing.T) { result := tool.Execute(ctx, args) // Should return error - if !result.IsError { t.Error("Expected error for missing task parameter") } - // ForLLM should contain helpful error with example - - if !strings.Contains(result.ForLLM, `"task"`) { - t.Errorf("Error message should mention '\"task\"', got: %s", result.ForLLM) - } - - if !strings.Contains(result.ForLLM, "Example") { - t.Errorf("Error message should include usage example, got: %s", result.ForLLM) + // ForLLM should contain error message about missing task parameter + if !strings.Contains(result.ForLLM, `Required parameter "task"`) { + t.Errorf("Error message should mention required task param, got: %s", result.ForLLM) } // Err should be set - if result.Err == nil { t.Error("Err should be set for validation failure") } } // TestSubagentTool_Execute_NilManager tests error handling for nil manager - func TestSubagentTool_Execute_NilManager(t *testing.T) { tool := NewSubagentTool(nil) ctx := context.Background() - args := map[string]any{ "task": "test task", } @@ -367,37 +263,24 @@ func TestSubagentTool_Execute_NilManager(t *testing.T) { result := tool.Execute(ctx, args) // Should return error - if !result.IsError { t.Error("Expected error for nil manager") } - if !strings.Contains(result.ForLLM, "not available in this session") { - t.Errorf("Error message should mention 'not available in this session', got: %s", result.ForLLM) + if !strings.Contains(result.ForLLM, "not available") { + t.Errorf("Error message should mention 'not available', got: %s", result.ForLLM) } } // TestSubagentTool_Execute_ContextPassing verifies context is properly used - func TestSubagentTool_Execute_ContextPassing(t *testing.T) { provider := &MockLLMProvider{} - - msgBus := bus.NewMessageBus() - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) - + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) tool := NewSubagentTool(manager) - // Set context - channel := "test-channel" - chatID := "test-chat" - - tool.SetContext(channel, chatID) - - ctx := context.Background() - + ctx := WithToolContext(context.Background(), channel, chatID) args := map[string]any{ "task": "Test context passing", } @@ -405,144 +288,40 @@ func TestSubagentTool_Execute_ContextPassing(t *testing.T) { result := tool.Execute(ctx, args) // Should succeed - if result.IsError { t.Errorf("Expected success with context, got error: %s", result.ForLLM) } // The context is used internally; we can't directly test it - // but execution success indicates context was handled properly } // TestSubagentTool_ForUserTruncation verifies long content is truncated for user - func TestSubagentTool_ForUserTruncation(t *testing.T) { // Create a mock provider that returns very long content - provider := &MockLLMProvider{} - - msgBus := bus.NewMessageBus() - - manager := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) - + manager := NewSubagentManager(provider, "test-model", "/tmp/test", nil, nil, WebSearchToolOptions{}) tool := NewSubagentTool(manager) ctx := context.Background() // Create a task that will generate long response - longTask := strings.Repeat("This is a very long task description. ", 100) - args := map[string]any{ - "task": longTask, - + "task": longTask, "label": "long-test", } result := tool.Execute(ctx, args) // ForUser should be truncated to 500 chars + "..." - maxUserLen := 500 - if len(result.ForUser) > maxUserLen+3 { // +3 for "..." t.Errorf("ForUser should be truncated to ~%d chars, got: %d", maxUserLen, len(result.ForUser)) } // ForLLM should have full content - if !strings.Contains(result.ForLLM, longTask[:50]) { t.Error("ForLLM should contain reference to original task") } } - -func TestFormatToolStats(t *testing.T) { - tests := []struct { - name string - - stats map[string]int - - want string - }{ - {"empty", map[string]int{}, ""}, - - {"single", map[string]int{"exec": 3}, "exec:3"}, - - { - "multiple sorted", - - map[string]int{"read_file": 5, "exec": 3, "write_file": 1}, - - "exec:3,read_file:5,write_file:1", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := formatToolStats(tt.stats) - - if got != tt.want { - t.Errorf("formatToolStats(%v) = %q, want %q", tt.stats, got, tt.want) - } - }) - } -} - -// TestSubagentManager_Spawn_SetsMetadata verifies that the bus message from a - -// completed spawn includes execution statistics in Metadata. - -func TestSubagentManager_Spawn_SetsMetadata(t *testing.T) { - provider := &MockLLMProvider{} - - msgBus := bus.NewMessageBus() - - mgr := NewSubagentManager(provider, "test-model", "/tmp/test", msgBus, orch.Noop, WebSearchToolOptions{}) - - _, err := mgr.Spawn( - - context.Background(), - - "say hello", "meta-test", "", "cli", "direct", "", - - nil, - ) - if err != nil { - t.Fatalf("Spawn() error: %v", err) - } - - // Consume the inbound message from the bus - - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - - defer cancel() - - received, ok := msgBus.ConsumeInbound(ctx) - - if !ok { - t.Fatal("timed out waiting for bus message") - } - - if received.Channel != "system" { - t.Fatalf("expected channel 'system', got %q", received.Channel) - } - - if received.Metadata == nil { - t.Fatal("Metadata should not be nil") - } - - if received.Metadata["iterations"] != "1" { - t.Errorf("iterations = %q, want %q", received.Metadata["iterations"], "1") - } - - if received.Metadata["tool_calls"] != "0" { - t.Errorf("tool_calls = %q, want %q", received.Metadata["tool_calls"], "0") - } - - // duration_ms should be a non-negative number - - if received.Metadata["duration_ms"] == "" { - t.Error("duration_ms should be present") - } -} diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index f463ceaf7..a625edd38 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -1,11 +1,7 @@ // PicoClaw - Ultra-lightweight personal AI agent - // Inspired by and based on nanobot: https://github.com/HKUDS/nanobot - // License: MIT - // - // Copyright (c) 2026 PicoClaw contributors package tools @@ -14,6 +10,7 @@ import ( "context" "encoding/json" "fmt" + "sync" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/orch" @@ -22,7 +19,6 @@ import ( ) // ToolLoopConfig configures the tool execution loop. - type ToolLoopConfig struct { Provider providers.LLMProvider @@ -48,7 +44,6 @@ type ToolLoopConfig struct { } // ToolLoopResult contains the result of running the tool loop. - type ToolLoopResult struct { Content string @@ -60,16 +55,11 @@ type ToolLoopResult struct { } // RunToolLoop executes the LLM + tool call iteration loop. - // This is the core agent logic that can be reused by both main agent and subagents. - func RunToolLoop( ctx context.Context, - config ToolLoopConfig, - messages []providers.Message, - channel, chatID string, ) (*ToolLoopResult, error) { reporter := config.Reporter @@ -84,13 +74,14 @@ func RunToolLoop( toolStats := map[string]int{} + var mu sync.Mutex // protects totalToolCalls and toolStats during parallel execution + var finalContent string for iteration < config.MaxIterations { iteration++ logger.DebugCF("toolloop", "LLM iteration", - map[string]any{ "iteration": iteration, @@ -98,17 +89,13 @@ func RunToolLoop( }) // 1. Build tool definitions - var providerToolDefs []providers.ToolDefinition - if config.Tools != nil { providerToolDefs = config.Tools.ToProviderDefs() } // 2. Set default LLM options - llmOpts := config.LLMOptions - if llmOpts == nil { llmOpts = map[string]any{} } @@ -120,48 +107,37 @@ func RunToolLoop( response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", - map[string]any{ "iteration": iteration, "error": err.Error(), }) - return nil, fmt.Errorf("LLM call failed: %w", err) } // 4. If no tool calls, we're done - if len(response.ToolCalls) == 0 { finalContent = response.Content - logger.InfoCF("toolloop", "LLM response without tool calls (direct answer)", - map[string]any{ "iteration": iteration, "content_chars": len(finalContent), }) - break } normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) - for _, tc := range response.ToolCalls { normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) } // 5. Log tool calls - toolNames := make([]string, 0, len(normalizedToolCalls)) - for _, tc := range normalizedToolCalls { toolNames = append(toolNames, tc.Name) } - logger.InfoCF("toolloop", "LLM requested tool calls", - map[string]any{ "tools": toolNames, @@ -171,13 +147,11 @@ func RunToolLoop( }) // 6. Build assistant message with tool calls - assistantMsg := providers.Message{ Role: "assistant", Content: response.Content, } - for _, tc := range normalizedToolCalls { assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{ ID: tc.ID, @@ -187,7 +161,6 @@ func RunToolLoop( Name: tc.Name, Arguments: tc.Arguments, - Function: &providers.FunctionCall{ Name: tc.Name, @@ -195,59 +168,69 @@ func RunToolLoop( }, }) } - messages = append(messages, assistantMsg) - // 7. Execute tool calls (hook: toolcall per tool) + // 7. Execute tool calls in parallel (hook: toolcall per tool) + type indexedResult struct { + result *ToolResult + tc providers.ToolCall + } - for _, tc := range normalizedToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) + results := make([]indexedResult, len(normalizedToolCalls)) + var wg sync.WaitGroup - argsPreview := utils.Truncate(string(argsJSON), 200) + for i, tc := range normalizedToolCalls { + results[i].tc = tc - logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), + wg.Add(1) + go func(idx int, tc providers.ToolCall) { + defer wg.Done() - map[string]any{ - "tool": tc.Name, + argsJSON, _ := json.Marshal(tc.Arguments) - "iteration": iteration, - }) + argsPreview := utils.Truncate(string(argsJSON), 200) - reporter.ReportStateChange(config.AgentID, orch.AgentStateToolCall, tc.Name) + logger.InfoCF("toolloop", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview), - totalToolCalls++ + map[string]any{ + "tool": tc.Name, - toolStats[tc.Name]++ + "iteration": iteration, + }) - // Execute tool (no async callback for subagents - they run independently) + reporter.ReportStateChange(config.AgentID, orch.AgentStateToolCall, tc.Name) - var toolResult *ToolResult + mu.Lock() + totalToolCalls++ + toolStats[tc.Name]++ + mu.Unlock() - if config.Tools != nil { - toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil) - } else { - toolResult = ErrorResult("No tools available") + var toolResult *ToolResult + + if config.Tools != nil { + toolResult = config.Tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, channel, chatID, nil) + } else { + toolResult = ErrorResult("No tools available") + } + results[idx].result = toolResult + }(i, tc) + } + wg.Wait() + + // Append results in original order + for _, r := range results { + contentForLLM := r.result.ForLLM + if contentForLLM == "" && r.result.Err != nil { + contentForLLM = r.result.Err.Error() } - // Determine content for LLM - - contentForLLM := toolResult.ForLLM - - if contentForLLM == "" && toolResult.Err != nil { - contentForLLM = toolResult.Err.Error() - } - - // Add tool result message - - toolResultMsg := providers.Message{ + messages = append(messages, providers.Message{ Role: "tool", Content: contentForLLM, - ToolCallID: tc.ID, - } - - messages = append(messages, toolResultMsg) + ToolCallID: r.tc.ID, + }) } } diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 7081424c2..70a912cb7 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -4,12 +4,15 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" + "net" "net/http" "net/url" "regexp" "strings" + "sync/atomic" "time" ) @@ -30,7 +33,6 @@ const ( ) // Pre-compiled regexes for HTML text extraction - var ( reScript = regexp.MustCompile(`<script[\s\S]*?</script>`) @@ -39,7 +41,6 @@ var ( reTags = regexp.MustCompile(`<[^>]+>`) reWhitespace = regexp.MustCompile(`[^\S\n]+`) - reBlankLines = regexp.MustCompile(`\n{3,}`) // DuckDuckGo result extraction @@ -49,12 +50,47 @@ var ( reDDGSnippet = regexp.MustCompile(`<a class="result__snippet[^"]*".*?>([\s\S]*?)</a>`) ) -// createHTTPClient creates an HTTP client with optional proxy support +// APIKeyPool provides round-robin key rotation for multi-key API access. +type APIKeyPool struct { + keys []string + current uint32 +} +func NewAPIKeyPool(keys []string) *APIKeyPool { + return &APIKeyPool{keys: keys} +} + +type APIKeyIterator struct { + pool *APIKeyPool + startIdx uint32 + attempt uint32 +} + +func (p *APIKeyPool) NewIterator() *APIKeyIterator { + if len(p.keys) == 0 { + return &APIKeyIterator{pool: p} + } + idx := atomic.AddUint32(&p.current, 1) - 1 + return &APIKeyIterator{ + pool: p, + startIdx: idx, + } +} + +func (it *APIKeyIterator) Next() (string, bool) { + length := uint32(len(it.pool.keys)) + if length == 0 || it.attempt >= length { + return "", false + } + key := it.pool.keys[(it.startIdx+it.attempt)%length] + it.attempt++ + return key, true +} + +// createHTTPClient creates an HTTP client with optional proxy support func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { client := &http.Client{ Timeout: timeout, - Transport: &http.Transport{ MaxIdleConns: 10, @@ -71,26 +107,18 @@ func createHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, err if err != nil { return nil, fmt.Errorf("invalid proxy URL: %w", err) } - scheme := strings.ToLower(proxy.Scheme) - switch scheme { case "http", "https", "socks5", "socks5h": - default: - return nil, fmt.Errorf( - "unsupported proxy scheme %q (supported: http, https, socks5, socks5h)", - proxy.Scheme, ) } - if proxy.Host == "" { return nil, fmt.Errorf("invalid proxy URL: missing host") } - client.Transport.(*http.Transport).Proxy = http.ProxyURL(proxy) } else { client.Transport.(*http.Transport).Proxy = http.ProxyFromEnvironment @@ -142,168 +170,203 @@ func formatWebSearchResults(query, provider string, results []searchResultItem, } type BraveSearchProvider struct { - apiKey string - - proxy string - - client *http.Client + keyPool *APIKeyPool + proxy string + client *http.Client } func (p *BraveSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d", - url.QueryEscape(query), count) - req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) + var lastErr error + iter := p.keyPool.NewIterator() + + for { + apiKey, ok := iter.Next() + if !ok { + break + } + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-Subscription-Token", apiKey) + + resp, err := p.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusTooManyRequests || + resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden || + resp.StatusCode >= 500 { + continue + } + return "", lastErr + } + + var searchResp struct { + Web struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` + } `json:"results"` + } `json:"web"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.Web.Results + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + items := make([]searchResultItem, 0, len(results)) + + for _, item := range results { + items = append(items, searchResultItem{ + Title: item.Title, + + URL: item.URL, + + Snippet: item.Description, + }) + } + + return formatWebSearchResults(query, "Brave", items, count), nil } - req.Header.Set("Accept", "application/json") + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) +} - req.Header.Set("X-Subscription-Token", p.apiKey) +type TavilySearchProvider struct { + keyPool *APIKeyPool + baseURL string + proxy string + client *http.Client +} - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) +func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := p.baseURL + if searchURL == "" { + searchURL = "https://api.tavily.com/search" } - defer resp.Body.Close() + var lastErr error + iter := p.keyPool.NewIterator() - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } + for { + apiKey, ok := iter.Next() + if !ok { + break + } - var searchResp struct { - Web struct { + payload := map[string]any{ + "api_key": apiKey, + + "query": query, + + "search_depth": "advanced", + + "include_answer": false, + + "include_images": false, + + "include_raw_content": false, + + "max_results": count, + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + req.Header.Set("User-Agent", userAgent) + + resp, err := p.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body)) + if resp.StatusCode == http.StatusTooManyRequests || + resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden || + resp.StatusCode >= 500 { + continue + } + return "", lastErr + } + + var searchResp struct { Results []struct { Title string `json:"title"` URL string `json:"url"` - Description string `json:"description"` + Content string `json:"content"` } `json:"results"` - } `json:"web"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + results := searchResp.Results + + items := make([]searchResultItem, 0, len(results)) + + for _, item := range results { + items = append(items, searchResultItem{ + Title: item.Title, + + URL: item.URL, + + Snippet: item.Content, + }) + } + + return formatWebSearchResults(query, "Tavily", items, count), nil } - if err := json.Unmarshal(body, &searchResp); err != nil { - // Log error body for debugging - - fmt.Printf("Brave API Error Body: %s\n", string(body)) - - return "", fmt.Errorf("failed to parse response: %w", err) - } - - results := searchResp.Web.Results - - items := make([]searchResultItem, 0, len(results)) - - for _, item := range results { - items = append(items, searchResultItem{ - Title: item.Title, - - URL: item.URL, - - Snippet: item.Description, - }) - } - - return formatWebSearchResults(query, "", items, count), nil -} - -type TavilySearchProvider struct { - apiKey string - - baseURL string - - proxy string - - client *http.Client -} - -func (p *TavilySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { - searchURL := p.baseURL - - if searchURL == "" { - searchURL = "https://api.tavily.com/search" - } - - payload := map[string]any{ - "api_key": p.apiKey, - - "query": query, - - "search_depth": "advanced", - - "include_answer": false, - - "include_images": false, - - "include_raw_content": false, - - "max_results": count, - } - - bodyBytes, err := json.Marshal(payload) - if err != nil { - return "", fmt.Errorf("failed to marshal payload: %w", err) - } - - req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewBuffer(bodyBytes)) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - - req.Header.Set("User-Agent", userAgent) - - resp, err := p.client.Do(req) - if err != nil { - return "", fmt.Errorf("request failed: %w", err) - } - - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return "", fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("tavily api error (status %d): %s", resp.StatusCode, string(body)) - } - - var searchResp struct { - Results []struct { - Title string `json:"title"` - - URL string `json:"url"` - - Content string `json:"content"` - } `json:"results"` - } - - if err := json.Unmarshal(body, &searchResp); err != nil { - return "", fmt.Errorf("failed to parse response: %w", err) - } - - results := searchResp.Results - - items := make([]searchResultItem, 0, len(results)) - - for _, item := range results { - items = append(items, searchResultItem{ - Title: item.Title, - - URL: item.URL, - - Snippet: item.Content, - }) - } - - return formatWebSearchResults(query, "Tavily", items, count), nil + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) } type DuckDuckGoSearchProvider struct { @@ -326,7 +389,6 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou if err != nil { return "", fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() body, err := io.ReadAll(resp.Body) @@ -339,15 +401,11 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) { // Simple regex based extraction for DDG HTML - // Strategy: Find all result containers or key anchors directly // Try finding the result links directly first, as they are the most critical - // Pattern: <a class="result__a" href="...">Title</a> - // The previous regex was a bit strict. Let's make it more flexible for attributes order/content - matches := reDDGLink.FindAllStringSubmatch(html, count+5) if len(matches) == 0 { @@ -362,17 +420,13 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query for i := range maxItems { urlStr := matches[i][1] - title := stripTags(matches[i][2]) - title = strings.TrimSpace(title) // URL decoding if needed - if strings.Contains(urlStr, "uddg=") { if u, err := url.QueryUnescape(urlStr); err == nil { _, after, ok := strings.Cut(u, "uddg=") - if ok { urlStr = after } @@ -382,7 +436,6 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query snippet := "" // Attempt to attach snippet if available and index aligns - if i < len(snippetMatches) { snippet = stripTags(snippetMatches[i][1]) @@ -406,85 +459,237 @@ func stripTags(content string) string { } type PerplexitySearchProvider struct { - apiKey string - - proxy string - - client *http.Client + keyPool *APIKeyPool + proxy string + client *http.Client } func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { searchURL := "https://api.perplexity.ai/chat/completions" - payload := map[string]any{ - "model": "sonar", + var lastErr error + iter := p.keyPool.NewIterator() - "messages": []map[string]string{ - { - "role": "system", + for { + apiKey, ok := iter.Next() + if !ok { + break + } - "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.", + payload := map[string]any{ + "model": "sonar", + "messages": []map[string]string{ + { + "role": "system", + "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary.", + }, + { + "role": "user", + "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count), + }, }, + "max_tokens": 1000, + } - { - "role": "user", + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } - "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count), - }, - }, + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(payloadBytes)) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } - "max_tokens": 1000, + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+apiKey) + req.Header.Set("User-Agent", userAgent) + + resp, err := p.client.Do(req) + if err != nil { + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + + if err != nil { + lastErr = fmt.Errorf("failed to read response: %w", err) + continue + } + + if resp.StatusCode != http.StatusOK { + lastErr = fmt.Errorf("Perplexity API error: %s", string(body)) + if resp.StatusCode == http.StatusTooManyRequests || + resp.StatusCode == http.StatusUnauthorized || + resp.StatusCode == http.StatusForbidden || + resp.StatusCode >= 500 { + continue + } + return "", lastErr + } + + var searchResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(searchResp.Choices) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil } - payloadBytes, err := json.Marshal(payload) + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) +} + +type SearXNGSearchProvider struct { + baseURL string +} + +func (p *SearXNGSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := fmt.Sprintf("%s/search?q=%s&format=json&categories=general", + strings.TrimSuffix(p.baseURL, "/"), + url.QueryEscape(query)) + + req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil) if err != nil { - return "", fmt.Errorf("failed to marshal request: %w", err) + return "", fmt.Errorf("failed to create request: %w", err) } - req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(payloadBytes)) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("SearXNG returned status %d", resp.StatusCode) + } + + var result struct { + Results []struct { + Title string `json:"title"` + URL string `json:"url"` + Content string `json:"content"` + Engine string `json:"engine"` + Score float64 `json:"score"` + } `json:"results"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(result.Results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + // Limit results to requested count + if len(result.Results) > count { + result.Results = result.Results[:count] + } + + // Format results in standard PicoClaw format + items := make([]searchResultItem, 0, len(result.Results)) + for _, r := range result.Results { + items = append(items, searchResultItem{ + Title: r.Title, + URL: r.URL, + Snippet: r.Content, + }) + } + + return formatWebSearchResults(query, "SearXNG", items, count), nil +} + +type GLMSearchProvider struct { + apiKey string + baseURL string + searchEngine string + proxy string + client *http.Client +} + +func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := p.baseURL + if searchURL == "" { + searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search" + } + + payload := map[string]any{ + "search_query": query, + "search_engine": p.searchEngine, + "search_intent": false, + "count": count, + "content_size": "medium", + } + + bodyBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(bodyBytes)) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+p.apiKey) - req.Header.Set("User-Agent", userAgent) - resp, err := p.client.Do(req) if err != nil { return "", fmt.Errorf("request failed: %w", err) } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return "", fmt.Errorf("failed to read response: %w", err) } if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("Perplexity API error: %s", string(body)) + return "", fmt.Errorf("GLM Search API error (status %d): %s", resp.StatusCode, string(body)) } var searchResp struct { - Choices []struct { - Message struct { - Content string `json:"content"` - } `json:"message"` - } `json:"choices"` + SearchResult []struct { + Title string `json:"title"` + Content string `json:"content"` + Link string `json:"link"` + } `json:"search_result"` } if err := json.Unmarshal(body, &searchResp); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } - if len(searchResp.Choices) == 0 { + results := searchResp.SearchResult + if len(results) == 0 { return fmt.Sprintf("No results for: %s", query), nil } - return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil + items := make([]searchResultItem, 0, len(results)) + for _, item := range results { + items = append(items, searchResultItem{ + Title: item.Title, + URL: item.Link, + Snippet: item.Content, + }) + } + + return formatWebSearchResults(query, "GLM Search", items, count), nil } type WebSearchTool struct { @@ -502,31 +707,27 @@ func (t *WebSearchTool) ProviderName() string { } type WebSearchToolOptions struct { - BraveAPIKey string - - BraveMaxResults int - - BraveEnabled bool - - TavilyAPIKey string - - TavilyBaseURL string - - TavilyMaxResults int - - TavilyEnabled bool - + BraveAPIKeys []string + BraveMaxResults int + BraveEnabled bool + TavilyAPIKeys []string + TavilyBaseURL string + TavilyMaxResults int + TavilyEnabled bool DuckDuckGoMaxResults int - - DuckDuckGoEnabled bool - - PerplexityAPIKey string - + DuckDuckGoEnabled bool + PerplexityAPIKeys []string PerplexityMaxResults int - - PerplexityEnabled bool - - Proxy string + PerplexityEnabled bool + SearXNGBaseURL string + SearXNGMaxResults int + SearXNGEnabled bool + GLMSearchAPIKey string + GLMSearchBaseURL string + GLMSearchEngine string + GLMSearchMaxResults int + GLMSearchEnabled bool + Proxy string } func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { @@ -536,43 +737,50 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { maxResults := 5 - // Priority: Perplexity > Brave > Tavily > DuckDuckGo - - if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { + // Priority: Perplexity > Brave > SearXNG > Tavily > DuckDuckGo > GLM Search + if opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 { client, err := createHTTPClient(opts.Proxy, perplexityTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Perplexity: %w", err) } - - provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey, proxy: opts.Proxy, client: client} + provider = &PerplexitySearchProvider{ + keyPool: NewAPIKeyPool(opts.PerplexityAPIKeys), + proxy: opts.Proxy, + client: client, + } providerName = "perplexity" if opts.PerplexityMaxResults > 0 { maxResults = opts.PerplexityMaxResults } - } else if opts.BraveEnabled && opts.BraveAPIKey != "" { + } else if opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 { client, err := createHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Brave: %w", err) } - - provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey, proxy: opts.Proxy, client: client} + provider = &BraveSearchProvider{keyPool: NewAPIKeyPool(opts.BraveAPIKeys), proxy: opts.Proxy, client: client} providerName = "brave" if opts.BraveMaxResults > 0 { maxResults = opts.BraveMaxResults } - } else if opts.TavilyEnabled && opts.TavilyAPIKey != "" { + } else if opts.SearXNGEnabled && opts.SearXNGBaseURL != "" { + provider = &SearXNGSearchProvider{baseURL: opts.SearXNGBaseURL} + + providerName = "searxng" + + if opts.SearXNGMaxResults > 0 { + maxResults = opts.SearXNGMaxResults + } + } else if opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 { client, err := createHTTPClient(opts.Proxy, searchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for Tavily: %w", err) } - provider = &TavilySearchProvider{ - apiKey: opts.TavilyAPIKey, - + keyPool: NewAPIKeyPool(opts.TavilyAPIKeys), baseURL: opts.TavilyBaseURL, proxy: opts.Proxy, @@ -590,7 +798,6 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if err != nil { return nil, fmt.Errorf("failed to create HTTP client for DuckDuckGo: %w", err) } - provider = &DuckDuckGoSearchProvider{proxy: opts.Proxy, client: client} providerName = "duckduckgo" @@ -598,6 +805,28 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) { if opts.DuckDuckGoMaxResults > 0 { maxResults = opts.DuckDuckGoMaxResults } + } else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" { + client, err := createHTTPClient(opts.Proxy, searchTimeout) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err) + } + searchEngine := opts.GLMSearchEngine + if searchEngine == "" { + searchEngine = "search_std" + } + provider = &GLMSearchProvider{ + apiKey: opts.GLMSearchAPIKey, + baseURL: opts.GLMSearchBaseURL, + searchEngine: searchEngine, + proxy: opts.Proxy, + client: client, + } + + providerName = "glm" + + if opts.GLMSearchMaxResults > 0 { + maxResults = opts.GLMSearchMaxResults + } } else { return nil, nil } @@ -622,14 +851,12 @@ func (t *WebSearchTool) Description() string { func (t *WebSearchTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "query": map[string]any{ "type": "string", "description": "Search query", }, - "count": map[string]any{ "type": "integer", @@ -640,20 +867,17 @@ func (t *WebSearchTool) Parameters() map[string]any { "maximum": 10.0, }, }, - "required": []string{"query"}, } } func (t *WebSearchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { query, ok := args["query"].(string) - if !ok { return ErrorResult("query is required") } count := t.maxResults - if c, ok := args["count"].(float64); ok { if int(c) > 0 && int(c) <= 10 { count = int(c) @@ -678,33 +902,51 @@ type WebFetchTool struct { proxy string client *http.Client + + fetchLimitBytes int64 } -func NewWebFetchTool(maxChars int) *WebFetchTool { - // createHTTPClient cannot fail with an empty proxy string. - - tool, _ := NewWebFetchToolWithProxy(maxChars, "") - - return tool +// NewWebFetchTool creates a WebFetchTool. The optional fetchLimitBytes parameter +// sets the maximum response body size (defaults to 10MB if not provided or <= 0). +func NewWebFetchTool(maxChars int, fetchLimitBytes ...int64) (*WebFetchTool, error) { + var limit int64 + if len(fetchLimitBytes) > 0 { + limit = fetchLimitBytes[0] + } + return NewWebFetchToolWithProxy(maxChars, "", limit) } -func NewWebFetchToolWithProxy(maxChars int, proxy string) (*WebFetchTool, error) { +// allowPrivateWebFetchHosts controls whether loopback/private hosts are allowed. +// This is false in normal runtime to reduce SSRF exposure, and tests can override it temporarily. +var allowPrivateWebFetchHosts atomic.Bool + +func NewWebFetchToolWithProxy(maxChars int, proxy string, fetchLimitBytes int64) (*WebFetchTool, error) { if maxChars <= 0 { maxChars = defaultMaxChars } - client, err := createHTTPClient(proxy, fetchTimeout) if err != nil { return nil, fmt.Errorf("failed to create HTTP client for web fetch: %w", err) } - + if transport, ok := client.Transport.(*http.Transport); ok { + dialer := &net.Dialer{ + Timeout: 15 * time.Second, + KeepAlive: 30 * time.Second, + } + transport.DialContext = newSafeDialContext(dialer) + } client.CheckRedirect = func(req *http.Request, via []*http.Request) error { if len(via) >= maxRedirects { return fmt.Errorf("stopped after %d redirects", maxRedirects) } - + if isObviousPrivateHost(req.URL.Hostname()) { + return fmt.Errorf("redirect target is private or local network host") + } return nil } + if fetchLimitBytes <= 0 { + fetchLimitBytes = 10 * 1024 * 1024 // Security Fallback + } return &WebFetchTool{ maxChars: maxChars, @@ -712,6 +954,8 @@ func NewWebFetchToolWithProxy(maxChars int, proxy string) (*WebFetchTool, error) proxy: proxy, client: client, + + fetchLimitBytes: fetchLimitBytes, }, nil } @@ -726,14 +970,12 @@ func (t *WebFetchTool) Description() string { func (t *WebFetchTool) Parameters() map[string]any { return map[string]any{ "type": "object", - "properties": map[string]any{ "url": map[string]any{ "type": "string", "description": "URL to fetch", }, - "maxChars": map[string]any{ "type": "integer", @@ -742,14 +984,12 @@ func (t *WebFetchTool) Parameters() map[string]any { "minimum": 100.0, }, }, - "required": []string{"url"}, } } func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolResult { urlStr, ok := args["url"].(string) - if !ok { return ErrorResult("url is required") } @@ -767,8 +1007,14 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe return ErrorResult("missing domain in URL") } - maxChars := t.maxChars + // Lightweight pre-flight: block obvious localhost/literal-IP without DNS resolution. + // The real SSRF guard is newSafeDialContext at connect time. + hostname := parsedURL.Hostname() + if isObviousPrivateHost(hostname) { + return ErrorResult("fetching private or local network hosts is not allowed") + } + maxChars := t.maxChars if mc, ok := args["maxChars"].(float64); ok { if int(mc) > 100 { maxChars = int(mc) @@ -781,16 +1027,21 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe } req.Header.Set("User-Agent", userAgent) - resp, err := t.client.Do(req) if err != nil { return ErrorResult(fmt.Sprintf("request failed: %v", err)) } + resp.Body = http.MaxBytesReader(nil, resp.Body, t.fetchLimitBytes) + defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { + return ErrorResult(fmt.Sprintf("failed to read response: size exceeded %d bytes limit", t.fetchLimitBytes)) + } return ErrorResult(fmt.Sprintf("failed to read response: %v", err)) } @@ -802,12 +1053,9 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe if strings.Contains(contentType, "application/json") { var jsonData any - if err := json.Unmarshal(body, &jsonData); err == nil { formatted, _ := json.MarshalIndent(jsonData, "", " ") - text = string(formatted) - extractor = "json" } else { text = bodyStr @@ -827,7 +1075,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe } truncated := len(text) > maxChars - if truncated { text = text[:maxChars] } @@ -838,7 +1085,6 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe "status": resp.StatusCode, "extractor": extractor, - "truncated": truncated, "length": len(text), @@ -849,34 +1095,25 @@ func (t *WebFetchTool) Execute(ctx context.Context, args map[string]any) *ToolRe resultJSON, _ := json.MarshalIndent(result, "", " ") return &ToolResult{ - ForLLM: fmt.Sprintf( - + ForLLM: string(resultJSON), + ForUser: fmt.Sprintf( "Fetched %d bytes from %s (extractor: %s, truncated: %v)", - len(text), - urlStr, - extractor, - truncated, ), - - ForUser: string(resultJSON), } } func (t *WebFetchTool) extractText(htmlContent string) string { result := reScript.ReplaceAllLiteralString(htmlContent, "") - result = reStyle.ReplaceAllLiteralString(result, "") - result = reTags.ReplaceAllLiteralString(result, "") result = strings.TrimSpace(result) result = reWhitespace.ReplaceAllString(result, " ") - result = reBlankLines.ReplaceAllString(result, "\n\n") lines := strings.Split(result, "\n") @@ -885,7 +1122,6 @@ func (t *WebFetchTool) extractText(htmlContent string) string { for _, line := range lines { line = strings.TrimSpace(line) - if line != "" { if sb.Len() > 0 { sb.WriteByte('\n') @@ -897,3 +1133,127 @@ func (t *WebFetchTool) extractText(htmlContent string) string { return sb.String() } + +// newSafeDialContext re-resolves DNS at connect time to mitigate DNS rebinding (TOCTOU) +// where a hostname resolves to a public IP during pre-flight but a private IP at connect time. +func newSafeDialContext(dialer *net.Dialer) func(context.Context, string, string) (net.Conn, error) { + return func(ctx context.Context, network, address string) (net.Conn, error) { + if allowPrivateWebFetchHosts.Load() { + return dialer.DialContext(ctx, network, address) + } + + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("invalid target address %q: %w", address, err) + } + if host == "" { + return nil, fmt.Errorf("empty target host") + } + + if ip := net.ParseIP(host); ip != nil { + if isPrivateOrRestrictedIP(ip) { + return nil, fmt.Errorf("blocked private or local target: %s", host) + } + return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port)) + } + + ipAddrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, fmt.Errorf("failed to resolve %s: %w", host, err) + } + + attempted := 0 + var lastErr error + for _, ipAddr := range ipAddrs { + if isPrivateOrRestrictedIP(ipAddr.IP) { + continue + } + attempted++ + conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipAddr.IP.String(), port)) + if err == nil { + return conn, nil + } + lastErr = err + } + + if attempted == 0 { + return nil, fmt.Errorf("all resolved addresses for %s are private or restricted", host) + } + if lastErr != nil { + return nil, fmt.Errorf("failed connecting to public addresses for %s: %w", host, lastErr) + } + return nil, fmt.Errorf("failed connecting to public addresses for %s", host) + } +} + +// isObviousPrivateHost performs a lightweight, no-DNS check for obviously private hosts. +// It catches localhost, literal private IPs, and empty hosts. It does NOT resolve DNS — +// the real SSRF guard is newSafeDialContext which checks IPs at connect time. +func isObviousPrivateHost(host string) bool { + if allowPrivateWebFetchHosts.Load() { + return false + } + + h := strings.ToLower(strings.TrimSpace(host)) + h = strings.TrimSuffix(h, ".") + if h == "" { + return true + } + + if h == "localhost" || strings.HasSuffix(h, ".localhost") { + return true + } + + if ip := net.ParseIP(h); ip != nil { + return isPrivateOrRestrictedIP(ip) + } + + return false +} + +// isPrivateOrRestrictedIP returns true for IPs that should never be reached via web_fetch: +// RFC 1918, loopback, link-local (incl. cloud metadata 169.254.x.x), carrier-grade NAT, +// IPv6 unique-local (fc00::/7), 6to4 (2002::/16), and Teredo (2001:0000::/32). +func isPrivateOrRestrictedIP(ip net.IP) bool { + if ip == nil { + return true + } + + if ip.IsLoopback() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || + ip.IsMulticast() || ip.IsUnspecified() { + return true + } + + if ip4 := ip.To4(); ip4 != nil { + // IPv4 private, loopback, link-local, and carrier-grade NAT ranges. + if ip4[0] == 10 || + ip4[0] == 127 || + ip4[0] == 0 || + (ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31) || + (ip4[0] == 192 && ip4[1] == 168) || + (ip4[0] == 169 && ip4[1] == 254) || + (ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127) { + return true + } + return false + } + + if len(ip) == net.IPv6len { + // IPv6 unique local addresses (fc00::/7) + if (ip[0] & 0xfe) == 0xfc { + return true + } + // 6to4 addresses (2002::/16): check the embedded IPv4 at bytes [2:6]. + if ip[0] == 0x20 && ip[1] == 0x02 { + embedded := net.IPv4(ip[2], ip[3], ip[4], ip[5]) + return isPrivateOrRestrictedIP(embedded) + } + // Teredo (2001:0000::/32): client IPv4 is at bytes [12:16], XOR-inverted. + if ip[0] == 0x20 && ip[1] == 0x01 && ip[2] == 0x00 && ip[3] == 0x00 { + client := net.IPv4(ip[12]^0xff, ip[13]^0xff, ip[14]^0xff, ip[15]^0xff) + return isPrivateOrRestrictedIP(client) + } + } + + return false +} diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go index 5758fed11..0737d2087 100644 --- a/pkg/tools/web_test.go +++ b/pkg/tools/web_test.go @@ -1,32 +1,39 @@ package tools import ( + "bytes" "context" "encoding/json" + "fmt" + "net" "net/http" "net/http/httptest" "strings" "testing" "time" + + "github.com/sipeed/picoclaw/pkg/logger" ) -// TestWebTool_WebFetch_Success verifies successful URL fetching +const testFetchLimit = int64(10 * 1024 * 1024) +// TestWebTool_WebFetch_Success verifies successful URL fetching func TestWebTool_WebFetch_Success(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - w.Write([]byte("<html><body><h1>Test Page</h1><p>Content here</p></body></html>")) })) - defer server.Close() - tool := NewWebFetchTool(50000) + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } ctx := context.Background() - args := map[string]any{ "url": server.URL, } @@ -34,45 +41,41 @@ func TestWebTool_WebFetch_Success(t *testing.T) { result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } - // ForUser should contain the fetched content - - if !strings.Contains(result.ForUser, "Test Page") { - t.Errorf("Expected ForUser to contain 'Test Page', got: %s", result.ForUser) + // ForLLM should contain the fetched content (full JSON result) + if !strings.Contains(result.ForLLM, "Test Page") { + t.Errorf("Expected ForLLM to contain 'Test Page', got: %s", result.ForLLM) } - // ForLLM should contain summary - - if !strings.Contains(result.ForLLM, "bytes") && !strings.Contains(result.ForLLM, "extractor") { - t.Errorf("Expected ForLLM to contain summary, got: %s", result.ForLLM) + // ForUser should contain summary + if !strings.Contains(result.ForUser, "bytes") && !strings.Contains(result.ForUser, "extractor") { + t.Errorf("Expected ForUser to contain summary, got: %s", result.ForUser) } } // TestWebTool_WebFetch_JSON verifies JSON content handling - func TestWebTool_WebFetch_JSON(t *testing.T) { - testData := map[string]string{"key": "value", "number": "123"} + withPrivateWebFetchHostsAllowed(t) + testData := map[string]string{"key": "value", "number": "123"} expectedJSON, _ := json.MarshalIndent(testData, "", " ") server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - w.Write(expectedJSON) })) - defer server.Close() - tool := NewWebFetchTool(50000) + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } ctx := context.Background() - args := map[string]any{ "url": server.URL, } @@ -80,25 +83,24 @@ func TestWebTool_WebFetch_JSON(t *testing.T) { result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } - // ForUser should contain formatted JSON - - if !strings.Contains(result.ForUser, "key") && !strings.Contains(result.ForUser, "value") { - t.Errorf("Expected ForUser to contain JSON data, got: %s", result.ForUser) + // ForLLM should contain formatted JSON + if !strings.Contains(result.ForLLM, "key") && !strings.Contains(result.ForLLM, "value") { + t.Errorf("Expected ForLLM to contain JSON data, got: %s", result.ForLLM) } } // TestWebTool_WebFetch_InvalidURL verifies error handling for invalid URL - func TestWebTool_WebFetch_InvalidURL(t *testing.T) { - tool := NewWebFetchTool(50000) + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } ctx := context.Background() - args := map[string]any{ "url": "not-a-valid-url", } @@ -106,25 +108,24 @@ func TestWebTool_WebFetch_InvalidURL(t *testing.T) { result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error for invalid URL") } // Should contain error message (either "invalid URL" or scheme error) - if !strings.Contains(result.ForLLM, "URL") && !strings.Contains(result.ForUser, "URL") { t.Errorf("Expected error message for invalid URL, got ForLLM: %s", result.ForLLM) } } // TestWebTool_WebFetch_UnsupportedScheme verifies error handling for non-http URLs - func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { - tool := NewWebFetchTool(50000) + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } ctx := context.Background() - args := map[string]any{ "url": "ftp://example.com/file.txt", } @@ -132,61 +133,58 @@ func TestWebTool_WebFetch_UnsupportedScheme(t *testing.T) { result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error for unsupported URL scheme") } // Should mention only http/https allowed - if !strings.Contains(result.ForLLM, "http/https") && !strings.Contains(result.ForUser, "http/https") { t.Errorf("Expected scheme error message, got ForLLM: %s", result.ForLLM) } } // TestWebTool_WebFetch_MissingURL verifies error handling for missing URL - func TestWebTool_WebFetch_MissingURL(t *testing.T) { - tool := NewWebFetchTool(50000) + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } ctx := context.Background() - args := map[string]any{} result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when URL is missing") } // Should mention URL is required - if !strings.Contains(result.ForLLM, "url is required") && !strings.Contains(result.ForUser, "url is required") { t.Errorf("Expected 'url is required' message, got ForLLM: %s", result.ForLLM) } } // TestWebTool_WebFetch_Truncation verifies content truncation - func TestWebTool_WebFetch_Truncation(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + longContent := strings.Repeat("x", 20000) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") - w.WriteHeader(http.StatusOK) - w.Write([]byte(longContent)) })) - defer server.Close() - tool := NewWebFetchTool(1000) // Limit to 1000 chars + tool, err := NewWebFetchTool(1000, testFetchLimit) // Limit to 1000 chars + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } ctx := context.Background() - args := map[string]any{ "url": server.URL, } @@ -194,17 +192,13 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } - // ForUser should contain truncated content (not the full 20000 chars) - + // ForLLM should contain truncated content (not the full 20000 chars) resultMap := make(map[string]any) - - json.Unmarshal([]byte(result.ForUser), &resultMap) - + json.Unmarshal([]byte(result.ForLLM), &resultMap) if text, ok := resultMap["text"].(string); ok { if len(text) > 1100 { // Allow some margin t.Errorf("Expected content to be truncated to ~1000 chars, got: %d", len(text)) @@ -212,79 +206,118 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) { } // Should be marked as truncated - if truncated, ok := resultMap["truncated"].(bool); !ok || !truncated { t.Errorf("Expected 'truncated' to be true in result") } } -// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing +func TestWebFetchTool_PayloadTooLarge(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + // Create a mock HTTP server + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + + // Generate a payload intentionally larger than our limit. + // Limit: 10 * 1024 * 1024 (10MB). We generate 10MB + 100 bytes of the letter 'A'. + largeData := bytes.Repeat([]byte("A"), int(testFetchLimit)+100) + + w.Write(largeData) + })) + // Ensure the server is shut down at the end of the test + defer ts.Close() + + // Initialize the tool + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } + + // Prepare the arguments pointing to the URL of our local mock server + args := map[string]any{ + "url": ts.URL, + } + + // Execute the tool + ctx := context.Background() + result := tool.Execute(ctx, args) + + // Assuming ErrorResult sets the ForLLM field with the error text. + if result == nil { + t.Fatal("expected a ToolResult, got nil") + } + + // Search for the exact error string we set earlier in the Execute method + expectedErrorMsg := fmt.Sprintf("size exceeded %d bytes limit", testFetchLimit) + + if !strings.Contains(result.ForLLM, expectedErrorMsg) && !strings.Contains(result.ForUser, expectedErrorMsg) { + t.Errorf("test failed: expected error %q, but got: %+v", expectedErrorMsg, result) + } +} + +// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing func TestWebTool_WebSearch_NoApiKey(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""}) + tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKeys: nil}) if err != nil { t.Fatalf("Unexpected error: %v", err) } - if tool != nil { t.Errorf("Expected nil tool when Brave API key is empty") } // Also nil when nothing is enabled - tool, err = NewWebSearchTool(WebSearchToolOptions{}) if err != nil { t.Fatalf("Unexpected error: %v", err) } - if tool != nil { t.Errorf("Expected nil tool when no provider is enabled") } } // TestWebTool_WebSearch_MissingQuery verifies error handling for missing query - func TestWebTool_WebSearch_MissingQuery(t *testing.T) { - tool, err := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5}) + tool, err := NewWebSearchTool(WebSearchToolOptions{ + BraveEnabled: true, + BraveAPIKeys: []string{"test-key"}, + BraveMaxResults: 5, + }) if err != nil { t.Fatalf("Unexpected error: %v", err) } - ctx := context.Background() - args := map[string]any{} result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error when query is missing") } } // TestWebTool_WebFetch_HTMLExtraction verifies HTML text extraction - func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") - w.WriteHeader(http.StatusOK) - w.Write( - []byte( `<html><body><script>alert('test');</script><style>body{color:red;}</style><h1>Title</h1><p>Content</p></body></html>`, ), ) })) - defer server.Close() - tool := NewWebFetchTool(50000) + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } ctx := context.Background() - args := map[string]any{ "url": server.URL, } @@ -292,105 +325,80 @@ func TestWebTool_WebFetch_HTMLExtraction(t *testing.T) { result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } - // ForUser should contain extracted text (without script/style tags) - - if !strings.Contains(result.ForUser, "Title") && !strings.Contains(result.ForUser, "Content") { - t.Errorf("Expected ForUser to contain extracted text, got: %s", result.ForUser) + // ForLLM should contain extracted text (without script/style tags) + if !strings.Contains(result.ForLLM, "Title") && !strings.Contains(result.ForLLM, "Content") { + t.Errorf("Expected ForLLM to contain extracted text, got: %s", result.ForLLM) } - // Should NOT contain script or style tags - - if strings.Contains(result.ForUser, "<script>") || strings.Contains(result.ForUser, "<style>") { - t.Errorf("Expected script/style tags to be removed, got: %s", result.ForUser) + // Should NOT contain script or style tags in ForLLM + if strings.Contains(result.ForLLM, "<script>") || strings.Contains(result.ForLLM, "<style>") { + t.Errorf("Expected script/style tags to be removed, got: %s", result.ForLLM) } } // TestWebFetchTool_extractText verifies text extraction preserves newlines - func TestWebFetchTool_extractText(t *testing.T) { tool := &WebFetchTool{} tests := []struct { - name string - - input string - + name string + input string wantFunc func(t *testing.T, got string) }{ { - name: "preserves newlines between block elements", - + name: "preserves newlines between block elements", input: "<html><body><h1>Title</h1>\n<p>Paragraph 1</p>\n<p>Paragraph 2</p></body></html>", - wantFunc: func(t *testing.T, got string) { lines := strings.Split(got, "\n") - if len(lines) < 2 { t.Errorf("Expected multiple lines, got %d: %q", len(lines), got) } - if !strings.Contains(got, "Title") || !strings.Contains(got, "Paragraph 1") || - !strings.Contains(got, "Paragraph 2") { t.Errorf("Missing expected text: %q", got) } }, }, - { - name: "removes script and style tags", - + name: "removes script and style tags", input: "<script>alert('x');</script><style>body{}</style><p>Keep this</p>", - wantFunc: func(t *testing.T, got string) { if strings.Contains(got, "alert") || strings.Contains(got, "body{}") { t.Errorf("Expected script/style content removed, got: %q", got) } - if !strings.Contains(got, "Keep this") { t.Errorf("Expected 'Keep this' to remain, got: %q", got) } }, }, - { - name: "collapses excessive blank lines", - + name: "collapses excessive blank lines", input: "<p>A</p>\n\n\n\n\n<p>B</p>", - wantFunc: func(t *testing.T, got string) { if strings.Contains(got, "\n\n\n") { t.Errorf("Expected excessive blank lines collapsed, got: %q", got) } }, }, - { - name: "collapses horizontal whitespace", - + name: "collapses horizontal whitespace", input: "<p>hello world</p>", - wantFunc: func(t *testing.T, got string) { if strings.Contains(got, " ") { t.Errorf("Expected spaces collapsed, got: %q", got) } - if !strings.Contains(got, "hello world") { t.Errorf("Expected 'hello world', got: %q", got) } }, }, - { - name: "empty input", - + name: "empty input", input: "", - wantFunc: func(t *testing.T, got string) { if got != "" { t.Errorf("Expected empty string, got: %q", got) @@ -402,19 +410,218 @@ func TestWebFetchTool_extractText(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := tool.extractText(tt.input) - tt.wantFunc(t, got) }) } } -// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain +func withPrivateWebFetchHostsAllowed(t *testing.T) { + t.Helper() + previous := allowPrivateWebFetchHosts.Load() + allowPrivateWebFetchHosts.Store(true) + t.Cleanup(func() { + allowPrivateWebFetchHosts.Store(previous) + }) +} +func TestWebTool_WebFetch_PrivateHostBlocked(t *testing.T) { + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://127.0.0.1:0", + }) + + if !result.IsError { + t.Errorf("expected error for private host URL, got success") + } + if !strings.Contains(result.ForLLM, "private or local network") && + !strings.Contains(result.ForUser, "private or local network") { + t.Errorf("expected private host block message, got %q", result.ForLLM) + } +} + +func TestWebTool_WebFetch_PrivateHostAllowedForTests(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + })) + defer server.Close() + + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": server.URL, + }) + + if result.IsError { + t.Errorf("expected success when private host access is allowed in tests, got %q", result.ForLLM) + } +} + +// TestWebFetch_BlocksIPv4MappedIPv6Loopback verifies ::ffff:127.0.0.1 is blocked +func TestWebFetch_BlocksIPv4MappedIPv6Loopback(t *testing.T) { + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://[::ffff:127.0.0.1]:0", + }) + + if !result.IsError { + t.Error("expected error for IPv4-mapped IPv6 loopback URL, got success") + } +} + +// TestWebFetch_BlocksMetadataIP verifies 169.254.169.254 is blocked +func TestWebFetch_BlocksMetadataIP(t *testing.T) { + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://169.254.169.254/latest/meta-data", + }) + + if !result.IsError { + t.Error("expected error for cloud metadata IP, got success") + } +} + +// TestWebFetch_BlocksIPv6UniqueLocal verifies fc00::/7 addresses are blocked +func TestWebFetch_BlocksIPv6UniqueLocal(t *testing.T) { + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://[fd00::1]:0", + }) + + if !result.IsError { + t.Error("expected error for IPv6 unique local address, got success") + } +} + +// TestWebFetch_Blocks6to4WithPrivateEmbed verifies 6to4 with private embedded IPv4 is blocked +func TestWebFetch_Blocks6to4WithPrivateEmbed(t *testing.T) { + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + // 2002:7f00:0001::1 embeds 127.0.0.1 + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://[2002:7f00:0001::1]:0", + }) + + if !result.IsError { + t.Error("expected error for 6to4 with private embedded IPv4, got success") + } +} + +// TestWebFetch_Allows6to4WithPublicEmbed verifies 6to4 with public embedded IPv4 is NOT blocked +func TestWebFetch_Allows6to4WithPublicEmbed(t *testing.T) { + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + // 2002:0801:0101::1 embeds 8.1.1.1 (public) — pre-flight should pass, + // connection will fail (no listener) but that's after the SSRF check. + result := tool.Execute(context.Background(), map[string]any{ + "url": "http://[2002:0801:0101::1]:0", + }) + + // Should NOT be blocked by SSRF check — error should be connection failure, not "private" + if result.IsError && strings.Contains(result.ForLLM, "private") { + t.Error("6to4 with public embedded IPv4 should not be blocked as private") + } +} + +// TestWebFetch_RedirectToPrivateBlocked verifies redirects to private IPs are blocked +func TestWebFetch_RedirectToPrivateBlocked(t *testing.T) { + withPrivateWebFetchHostsAllowed(t) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Redirect to a private IP + http.Redirect(w, r, "http://10.0.0.1/secret", http.StatusFound) + })) + defer server.Close() + + // Temporarily disable private host allowance for the redirect check + allowPrivateWebFetchHosts.Store(false) + defer allowPrivateWebFetchHosts.Store(true) + + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + t.Fatalf("Failed to create web fetch tool: %v", err) + } + result := tool.Execute(context.Background(), map[string]any{ + "url": server.URL, + }) + + if !result.IsError { + t.Error("expected error when redirecting to private IP, got success") + } +} + +// TestIsPrivateOrRestrictedIP_Table tests IP classification logic +func TestIsPrivateOrRestrictedIP_Table(t *testing.T) { + tests := []struct { + ip string + blocked bool + desc string + }{ + {"127.0.0.1", true, "IPv4 loopback"}, + {"10.0.0.1", true, "IPv4 private class A"}, + {"172.16.0.1", true, "IPv4 private class B"}, + {"192.168.1.1", true, "IPv4 private class C"}, + {"169.254.169.254", true, "link-local / cloud metadata"}, + {"100.64.0.1", true, "carrier-grade NAT"}, + {"0.0.0.0", true, "unspecified"}, + {"8.8.8.8", false, "public DNS"}, + {"1.1.1.1", false, "public DNS"}, + {"::1", true, "IPv6 loopback"}, + {"::ffff:127.0.0.1", true, "IPv4-mapped IPv6 loopback"}, + {"::ffff:10.0.0.1", true, "IPv4-mapped IPv6 private"}, + {"fc00::1", true, "IPv6 unique local"}, + {"fd00::1", true, "IPv6 unique local"}, + {"2002:7f00:0001::1", true, "6to4 with embedded 127.x (private)"}, + {"2002:0a00:0001::1", true, "6to4 with embedded 10.0.0.1 (private)"}, + {"2002:0801:0101::1", false, "6to4 with embedded 8.1.1.1 (public)"}, + {"2001:0000:4136:e378:8000:63bf:f5ff:fffe", true, "Teredo with client 10.0.0.1 (private)"}, + {"2001:0000:4136:e378:8000:63bf:f7f6:fefe", false, "Teredo with client 8.9.1.1 (public)"}, + {"2607:f8b0:4004:800::200e", false, "public IPv6 (Google)"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + ip := net.ParseIP(tt.ip) + if ip == nil { + t.Fatalf("failed to parse IP: %s", tt.ip) + } + got := isPrivateOrRestrictedIP(ip) + if got != tt.blocked { + t.Errorf("isPrivateOrRestrictedIP(%s) = %v, want %v", tt.ip, got, tt.blocked) + } + }) + } +} + +// TestWebTool_WebFetch_MissingDomain verifies error handling for URL without domain func TestWebTool_WebFetch_MissingDomain(t *testing.T) { - tool := NewWebFetchTool(50000) + tool, err := NewWebFetchTool(50000, testFetchLimit) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } ctx := context.Background() - args := map[string]any{ "url": "https://", } @@ -422,13 +629,11 @@ func TestWebTool_WebFetch_MissingDomain(t *testing.T) { result := tool.Execute(ctx, args) // Should return error result - if !result.IsError { t.Errorf("Expected error for URL without domain") } // Should mention missing domain - if !strings.Contains(result.ForLLM, "domain") && !strings.Contains(result.ForUser, "domain") { t.Errorf("Expected domain error message, got ForLLM: %s", result.ForLLM) } @@ -439,17 +644,14 @@ func TestCreateHTTPClient_ProxyConfigured(t *testing.T) { if err != nil { t.Fatalf("createHTTPClient() error: %v", err) } - if client.Timeout != 12*time.Second { t.Fatalf("client.Timeout = %v, want %v", client.Timeout, 12*time.Second) } tr, ok := client.Transport.(*http.Transport) - if !ok { t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) } - if tr.Proxy == nil { t.Fatal("transport.Proxy is nil, want non-nil") } @@ -458,12 +660,10 @@ func TestCreateHTTPClient_ProxyConfigured(t *testing.T) { if err != nil { t.Fatalf("http.NewRequest() error: %v", err) } - proxyURL, err := tr.Proxy(req) if err != nil { t.Fatalf("transport.Proxy(req) error: %v", err) } - if proxyURL == nil || proxyURL.String() != "http://127.0.0.1:7890" { t.Fatalf("proxy URL = %v, want %q", proxyURL, "http://127.0.0.1:7890") } @@ -471,7 +671,6 @@ func TestCreateHTTPClient_ProxyConfigured(t *testing.T) { func TestCreateHTTPClient_InvalidProxy(t *testing.T) { _, err := createHTTPClient("://bad-proxy", 10*time.Second) - if err == nil { t.Fatal("createHTTPClient() expected error for invalid proxy URL, got nil") } @@ -484,21 +683,17 @@ func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) { } tr, ok := client.Transport.(*http.Transport) - if !ok { t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) } - req, err := http.NewRequest("GET", "https://example.com", nil) if err != nil { t.Fatalf("http.NewRequest() error: %v", err) } - proxyURL, err := tr.Proxy(req) if err != nil { t.Fatalf("transport.Proxy(req) error: %v", err) } - if proxyURL == nil || proxyURL.String() != "socks5://127.0.0.1:1080" { t.Fatalf("proxy URL = %v, want %q", proxyURL, "socks5://127.0.0.1:1080") } @@ -506,11 +701,9 @@ func TestCreateHTTPClient_Socks5ProxyConfigured(t *testing.T) { func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) { _, err := createHTTPClient("ftp://127.0.0.1:21", 10*time.Second) - if err == nil { t.Fatal("createHTTPClient() expected error for unsupported scheme, got nil") } - if !strings.Contains(err.Error(), "unsupported proxy scheme") { t.Fatalf("error = %q, want to contain %q", err.Error(), "unsupported proxy scheme") } @@ -518,19 +711,12 @@ func TestCreateHTTPClient_UnsupportedProxyScheme(t *testing.T) { func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) { t.Setenv("HTTP_PROXY", "http://127.0.0.1:8888") - t.Setenv("http_proxy", "http://127.0.0.1:8888") - t.Setenv("HTTPS_PROXY", "http://127.0.0.1:8888") - t.Setenv("https_proxy", "http://127.0.0.1:8888") - t.Setenv("ALL_PROXY", "") - t.Setenv("all_proxy", "") - t.Setenv("NO_PROXY", "") - t.Setenv("no_proxy", "") client, err := createHTTPClient("", 10*time.Second) @@ -539,11 +725,9 @@ func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) { } tr, ok := client.Transport.(*http.Transport) - if !ok { t.Fatalf("client.Transport type = %T, want *http.Transport", client.Transport) } - if tr.Proxy == nil { t.Fatal("transport.Proxy is nil, want proxy function from environment") } @@ -552,19 +736,16 @@ func TestCreateHTTPClient_ProxyFromEnvironmentWhenConfigEmpty(t *testing.T) { if err != nil { t.Fatalf("http.NewRequest() error: %v", err) } - if _, err := tr.Proxy(req); err != nil { t.Fatalf("transport.Proxy(req) error: %v", err) } } func TestNewWebFetchToolWithProxy(t *testing.T) { - tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890") + tool, err := NewWebFetchToolWithProxy(1024, "http://127.0.0.1:7890", testFetchLimit) if err != nil { - t.Fatalf("NewWebFetchToolWithProxy() error: %v", err) - } - - if tool.maxChars != 1024 { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } else if tool.maxChars != 1024 { t.Fatalf("maxChars = %d, want %d", tool.maxChars, 1024) } @@ -572,9 +753,9 @@ func TestNewWebFetchToolWithProxy(t *testing.T) { t.Fatalf("proxy = %q, want %q", tool.proxy, "http://127.0.0.1:7890") } - tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890") + tool, err = NewWebFetchToolWithProxy(0, "http://127.0.0.1:7890", testFetchLimit) if err != nil { - t.Fatalf("NewWebFetchToolWithProxy() error: %v", err) + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) } if tool.maxChars != 50000 { @@ -585,24 +766,18 @@ func TestNewWebFetchToolWithProxy(t *testing.T) { func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { t.Run("perplexity", func(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ - PerplexityEnabled: true, - - PerplexityAPIKey: "k", - + PerplexityEnabled: true, + PerplexityAPIKeys: []string{"k"}, PerplexityMaxResults: 3, - - Proxy: "http://127.0.0.1:7890", + Proxy: "http://127.0.0.1:7890", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } - p, ok := tool.provider.(*PerplexitySearchProvider) - if !ok { t.Fatalf("provider type = %T, want *PerplexitySearchProvider", tool.provider) } - if p.proxy != "http://127.0.0.1:7890" { t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") } @@ -610,24 +785,18 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { t.Run("brave", func(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ - BraveEnabled: true, - - BraveAPIKey: "k", - + BraveEnabled: true, + BraveAPIKeys: []string{"k"}, BraveMaxResults: 3, - - Proxy: "http://127.0.0.1:7890", + Proxy: "http://127.0.0.1:7890", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } - p, ok := tool.provider.(*BraveSearchProvider) - if !ok { t.Fatalf("provider type = %T, want *BraveSearchProvider", tool.provider) } - if p.proxy != "http://127.0.0.1:7890" { t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") } @@ -635,22 +804,17 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { t.Run("duckduckgo", func(t *testing.T) { tool, err := NewWebSearchTool(WebSearchToolOptions{ - DuckDuckGoEnabled: true, - + DuckDuckGoEnabled: true, DuckDuckGoMaxResults: 3, - - Proxy: "http://127.0.0.1:7890", + Proxy: "http://127.0.0.1:7890", }) if err != nil { t.Fatalf("NewWebSearchTool() error: %v", err) } - p, ok := tool.provider.(*DuckDuckGoSearchProvider) - if !ok { t.Fatalf("provider type = %T, want *DuckDuckGoSearchProvider", tool.provider) } - if p.proxy != "http://127.0.0.1:7890" { t.Fatalf("provider proxy = %q, want %q", p.proxy, "http://127.0.0.1:7890") } @@ -658,69 +822,50 @@ func TestNewWebSearchTool_PropagatesProxy(t *testing.T) { } // TestWebTool_TavilySearch_Success verifies successful Tavily search - func TestWebTool_TavilySearch_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { t.Errorf("Expected POST request, got %s", r.Method) } - if r.Header.Get("Content-Type") != "application/json" { t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) } // Verify payload - var payload map[string]any - json.NewDecoder(r.Body).Decode(&payload) - if payload["api_key"] != "test-key" { t.Errorf("Expected api_key test-key, got %v", payload["api_key"]) } - if payload["query"] != "test query" { t.Errorf("Expected query 'test query', got %v", payload["query"]) } // Return mock response - response := map[string]any{ "results": []map[string]any{ { - "title": "Test Result 1", - - "url": "https://example.com/1", - + "title": "Test Result 1", + "url": "https://example.com/1", "content": "Content for result 1", }, - { - "title": "Test Result 2", - - "url": "https://example.com/2", - + "title": "Test Result 2", + "url": "https://example.com/2", "content": "Content for result 2", }, }, } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(response) })) - defer server.Close() tool, err := NewWebSearchTool(WebSearchToolOptions{ - TavilyEnabled: true, - - TavilyAPIKey: "test-key", - - TavilyBaseURL: server.URL, - + TavilyEnabled: true, + TavilyAPIKeys: []string{"test-key"}, + TavilyBaseURL: server.URL, TavilyMaxResults: 5, }) if err != nil { @@ -728,7 +873,6 @@ func TestWebTool_TavilySearch_Success(t *testing.T) { } ctx := context.Background() - args := map[string]any{ "query": "test query", } @@ -736,22 +880,265 @@ func TestWebTool_TavilySearch_Success(t *testing.T) { result := tool.Execute(ctx, args) // Success should not be an error - if result.IsError { t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) } // ForUser should contain result titles and URLs - if !strings.Contains(result.ForUser, "Test Result 1") || - !strings.Contains(result.ForUser, "https://example.com/1") { t.Errorf("Expected results in output, got: %s", result.ForUser) } // Should mention via Tavily - if !strings.Contains(result.ForUser, "via Tavily") { t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser) } } + +func TestAPIKeyPool(t *testing.T) { + pool := NewAPIKeyPool([]string{"key1", "key2", "key3"}) + if len(pool.keys) != 3 { + t.Fatalf("expected 3 keys, got %d", len(pool.keys)) + } + if pool.keys[0] != "key1" || pool.keys[1] != "key2" || pool.keys[2] != "key3" { + t.Fatalf("unexpected keys: %v", pool.keys) + } + + // Test Iterator: each iterator should cover all keys exactly once + iter := pool.NewIterator() + expected := []string{"key1", "key2", "key3"} + for i, want := range expected { + k, ok := iter.Next() + if !ok { + t.Fatalf("iter.Next() returned false at step %d", i) + } + if k != want { + t.Errorf("step %d: expected %s, got %s", i, want, k) + } + } + // Should be exhausted + if _, ok := iter.Next(); ok { + t.Errorf("expected iterator exhausted after all keys") + } + + // Second iterator starts at next position (load balancing) + iter2 := pool.NewIterator() + k, ok := iter2.Next() + if !ok { + t.Fatal("iter2.Next() returned false") + } + if k != "key2" { + t.Errorf("expected key2 (round-robin), got %s", k) + } + + // Empty pool + emptyPool := NewAPIKeyPool([]string{}) + emptyIter := emptyPool.NewIterator() + if _, ok := emptyIter.Next(); ok { + t.Errorf("expected false for empty pool") + } + + // Single key pool + singlePool := NewAPIKeyPool([]string{"single"}) + singleIter := singlePool.NewIterator() + if k, ok := singleIter.Next(); !ok || k != "single" { + t.Errorf("expected single, got %s (ok=%v)", k, ok) + } + if _, ok := singleIter.Next(); ok { + t.Errorf("expected exhausted after single key") + } +} + +func TestWebTool_TavilySearch_Failover(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + + apiKey := payload["api_key"].(string) + + if apiKey == "key1" { + w.WriteHeader(http.StatusTooManyRequests) + w.Write([]byte("Rate limited")) + return + } + + if apiKey == "key2" { + // Success + response := map[string]any{ + "results": []map[string]any{ + { + "title": "Success Result", + "url": "https://example.com/success", + "content": "Success content", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + return + } + + w.WriteHeader(http.StatusBadRequest) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + TavilyEnabled: true, + TavilyAPIKeys: []string{"key1", "key2"}, + TavilyBaseURL: server.URL, + TavilyMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + ctx := context.Background() + args := map[string]any{ + "query": "test query", + } + + result := tool.Execute(ctx, args) + + if result.IsError { + t.Errorf("Expected success, got Error: %s", result.ForLLM) + } + if !strings.Contains(result.ForUser, "Success Result") { + t.Errorf("Expected failover to second key and success result, got: %s", result.ForUser) + } +} + +func TestWebTool_GLMSearch_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.Header.Get("Content-Type") != "application/json" { + t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type")) + } + if r.Header.Get("Authorization") != "Bearer test-glm-key" { + t.Errorf("Expected Authorization Bearer test-glm-key, got %s", r.Header.Get("Authorization")) + } + + var payload map[string]any + json.NewDecoder(r.Body).Decode(&payload) + if payload["search_query"] != "test query" { + t.Errorf("Expected search_query 'test query', got %v", payload["search_query"]) + } + if payload["search_engine"] != "search_std" { + t.Errorf("Expected search_engine 'search_std', got %v", payload["search_engine"]) + } + + response := map[string]any{ + "id": "web-search-test", + "created": 1709568000, + "search_result": []map[string]any{ + { + "title": "Test GLM Result", + "content": "GLM search snippet", + "link": "https://example.com/glm", + "media": "Example", + "publish_date": "2026-03-04", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-glm-key", + GLMSearchBaseURL: server.URL, + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + }) + + if result.IsError { + t.Errorf("Expected success, got IsError=true: %s", result.ForLLM) + } + if !strings.Contains(result.ForUser, "Test GLM Result") { + t.Errorf("Expected 'Test GLM Result' in output, got: %s", result.ForUser) + } + if !strings.Contains(result.ForUser, "https://example.com/glm") { + t.Errorf("Expected URL in output, got: %s", result.ForUser) + } + if !strings.Contains(result.ForUser, "via GLM Search") { + t.Errorf("Expected 'via GLM Search' in output, got: %s", result.ForUser) + } +} + +func TestWebTool_GLMSearch_APIError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"invalid api key"}`)) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + GLMSearchEnabled: true, + GLMSearchAPIKey: "bad-key", + GLMSearchBaseURL: server.URL, + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + }) + + if !result.IsError { + t.Errorf("Expected IsError=true for 401 response") + } + if !strings.Contains(result.ForLLM, "status 401") { + t.Errorf("Expected status 401 in error, got: %s", result.ForLLM) + } +} + +func TestWebTool_GLMSearch_Priority(t *testing.T) { + // GLM Search should only be selected when all other providers are disabled + tool, err := NewWebSearchTool(WebSearchToolOptions{ + DuckDuckGoEnabled: true, + DuckDuckGoMaxResults: 5, + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-key", + GLMSearchBaseURL: "https://example.com", + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + // DuckDuckGo should win over GLM Search + if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok { + t.Errorf("Expected DuckDuckGoSearchProvider when both enabled, got %T", tool.provider) + } + + // With DuckDuckGo disabled, GLM Search should be selected + tool2, err := NewWebSearchTool(WebSearchToolOptions{ + DuckDuckGoEnabled: false, + GLMSearchEnabled: true, + GLMSearchAPIKey: "test-key", + GLMSearchBaseURL: "https://example.com", + GLMSearchEngine: "search_std", + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if _, ok := tool2.provider.(*GLMSearchProvider); !ok { + t.Errorf("Expected GLMSearchProvider when only GLM enabled, got %T", tool2.provider) + } +} diff --git a/pkg/utils/bm25.go b/pkg/utils/bm25.go new file mode 100644 index 000000000..95c63f0e3 --- /dev/null +++ b/pkg/utils/bm25.go @@ -0,0 +1,272 @@ +// Package utils provides shared, reusable algorithms. +// This file implements a generic BM25 search engine. +// +// Usage: +// +// type MyDoc struct { ID string; Body string } +// +// corpus := []MyDoc{...} +// engine := bm25.New(corpus, func(d MyDoc) string { +// return d.ID + " " + d.Body +// }) +// results := engine.Search("my query", 5) +package utils + +import ( + "math" + "sort" + "strings" +) + +// ── Tuning defaults ─────────────────────────────────────────────────────────── + +const ( + // DefaultBM25K1 is the term-frequency saturation factor (typical range 1.2–2.0). + // Higher values give more weight to repeated terms. + DefaultBM25K1 = 1.2 + + // DefaultBM25B is the document-length normalization factor (0 = none, 1 = full). + DefaultBM25B = 0.75 +) + +// BM25Engine is a query-time BM25 search engine over a generic corpus. +// T is the document type; the caller supplies a TextFunc that extracts the +// searchable text from each document. +// +// The engine is stateless between queries: no caching, no invalidation logic. +// All indexing work is performed inside Search() on every call, making it +// safe to use on corpora that change frequently. +type BM25Engine[T any] struct { + corpus []T + textFunc func(T) string + k1 float64 + b float64 +} + +// BM25Option is a functional option to configure a BM25Engine. +type BM25Option func(*bm25Config) + +type bm25Config struct { + k1 float64 + b float64 +} + +// WithK1 overrides the term-frequency saturation constant (default 1.2). +func WithK1(k1 float64) BM25Option { + return func(c *bm25Config) { c.k1 = k1 } +} + +// WithB overrides the document-length normalization factor (default 0.75). +func WithB(b float64) BM25Option { + return func(c *bm25Config) { c.b = b } +} + +// NewBM25Engine creates a BM25Engine for the given corpus. +// +// - corpus : slice of documents of any type T. +// - textFunc : function that returns the searchable text for a document. +// - opts : optional tuning (WithK1, WithB). +// +// The corpus slice is referenced, not copied. Callers must not mutate it +// concurrently with Search(). +func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Option) *BM25Engine[T] { + cfg := bm25Config{k1: DefaultBM25K1, b: DefaultBM25B} + for _, o := range opts { + o(&cfg) + } + return &BM25Engine[T]{ + corpus: corpus, + textFunc: textFunc, + k1: cfg.k1, + b: cfg.b, + } +} + +// BM25Result is a single ranked result from a Search call. +type BM25Result[T any] struct { + Document T + Score float32 +} + +// Search ranks the corpus against query and returns the top-k results. +// Returns an empty slice (not nil) when there are no matches. +// +// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring, +// where N = corpus size, L = average document length, Q = query terms. +// Top-k extraction uses a fixed-size min-heap: O(candidates × log k). +func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] { + if topK <= 0 { + return []BM25Result[T]{} + } + + queryTerms := bm25Tokenize(query) + if len(queryTerms) == 0 { + return []BM25Result[T]{} + } + + N := len(e.corpus) + if N == 0 { + return []BM25Result[T]{} + } + + // Step 1: build per-document tf + raw doc lengths + type docEntry struct { + tf map[string]uint32 + rawLen int + } + + entries := make([]docEntry, N) + df := make(map[string]int, 64) + totalLen := 0 + + for i, doc := range e.corpus { + tokens := bm25Tokenize(e.textFunc(doc)) + totalLen += len(tokens) + + tf := make(map[string]uint32, len(tokens)) + for _, t := range tokens { + tf[t]++ + } + // df: each term counts once per document (iterate the map, keys are unique) + for t := range tf { + df[t]++ + } + + entries[i] = docEntry{tf: tf, rawLen: len(tokens)} + } + + avgDocLen := float64(totalLen) / float64(N) + + // Step 2: pre-compute IDF and per-doc length normalization + // IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 ) + idf := make(map[string]float32, len(df)) + for term, freq := range df { + idf[term] = float32(math.Log( + (float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1, + )) + } + + // docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen) + // Stored as float32 — sufficient precision for ranking. + docLenNorm := make([]float32, N) + for i, entry := range entries { + docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen)) + } + + // Step 3: build inverted index (posting lists) + // Iterate the tf map directly — map keys are already unique, no seen-set needed. + posting := make(map[string][]int32, len(df)) + for i, entry := range entries { + for term := range entry.tf { + posting[term] = append(posting[term], int32(i)) + } + } + + // Step 4: score via posting lists + // Deduplicate query terms to avoid double-weighting the same term. + unique := bm25Dedupe(queryTerms) + + scores := make(map[int32]float32) + for _, term := range unique { + termIDF, ok := idf[term] + if !ok { + continue // term not in vocabulary → zero contribution + } + for _, docID := range posting[term] { + freq := float32(entries[docID].tf[term]) + // TF_norm = freq * (k1+1) / (freq + docLenNorm) + tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID]) + scores[docID] += termIDF * tfNorm + } + } + + if len(scores) == 0 { + return []BM25Result[T]{} + } + + // Step 5: top-K via fixed-size min-heap + heap := make([]bm25ScoredDoc, 0, topK) + + for docID, sc := range scores { + switch { + case len(heap) < topK: + heap = append(heap, bm25ScoredDoc{docID: docID, score: sc}) + if len(heap) == topK { + bm25MinHeapify(heap) + } + case sc > heap[0].score: + heap[0] = bm25ScoredDoc{docID: docID, score: sc} + bm25SiftDown(heap, 0) + } + } + + sort.Slice(heap, func(i, j int) bool { return heap[i].score > heap[j].score }) + + out := make([]BM25Result[T], len(heap)) + for i, h := range heap { + out[i] = BM25Result[T]{ + Document: e.corpus[h.docID], + Score: h.score, + } + } + return out +} + +// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation. +func bm25Tokenize(s string) []string { + raw := strings.Fields(strings.ToLower(s)) + out := raw[:0] // reuse backing array to avoid extra allocation + for _, t := range raw { + t = strings.Trim(t, ".,;:!?\"'()/\\-_") + if t != "" { + out = append(out, t) + } + } + return out +} + +// bm25Dedupe returns a new slice with duplicate tokens removed, +// preserving first-occurrence order. +func bm25Dedupe(tokens []string) []string { + seen := make(map[string]struct{}, len(tokens)) + out := make([]string, 0, len(tokens)) + for _, t := range tokens { + if _, ok := seen[t]; !ok { + seen[t] = struct{}{} + out = append(out, t) + } + } + return out +} + +type bm25ScoredDoc struct { + docID int32 + score float32 +} + +// bm25MinHeapify builds a min-heap in-place using Floyd's algorithm: O(k). +func bm25MinHeapify(h []bm25ScoredDoc) { + for i := len(h)/2 - 1; i >= 0; i-- { + bm25SiftDown(h, i) + } +} + +// bm25SiftDown restores the min-heap property starting at node i: O(log k). +func bm25SiftDown(h []bm25ScoredDoc, i int) { + n := len(h) + for { + smallest := i + l, r := 2*i+1, 2*i+2 + if l < n && h[l].score < h[smallest].score { + smallest = l + } + if r < n && h[r].score < h[smallest].score { + smallest = r + } + if smallest == i { + break + } + h[i], h[smallest] = h[smallest], h[i] + i = smallest + } +} diff --git a/pkg/utils/bm25_test.go b/pkg/utils/bm25_test.go new file mode 100644 index 000000000..4bc85b246 --- /dev/null +++ b/pkg/utils/bm25_test.go @@ -0,0 +1,175 @@ +package utils + +import ( + "reflect" + "testing" +) + +// testDoc is a generic structure for use in tests. +type testDoc struct { + ID int + Text string +} + +func extractText(d testDoc) string { + return d.Text +} + +func TestBM25Search_EdgeCases(t *testing.T) { + corpus := []testDoc{ + {1, "hello world"}, + {2, "foo bar"}, + } + engine := NewBM25Engine(corpus, extractText) + + tests := []struct { + name string + query string + topK int + }{ + {"Zero topK", "hello", 0}, + {"Negative topK", "hello", -1}, + {"Empty query", "", 5}, + {"Query with only punctuation", "...,,,!!!", 5}, + {"No matches found", "golang", 5}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + results := engine.Search(tt.query, tt.topK) + if len(results) != 0 { + t.Errorf("expected 0 results, got %d", len(results)) + } + // Check that it never returns nil, but an empty slice + if results == nil { + t.Errorf("expected empty slice, got nil") + } + }) + } +} + +func TestBM25Search_EmptyCorpus(t *testing.T) { + engine := NewBM25Engine([]testDoc{}, extractText) + results := engine.Search("hello", 5) + if len(results) != 0 || results == nil { + t.Errorf("expected empty slice from empty corpus, got %v", results) + } +} + +func TestBM25Search_RankingLogic(t *testing.T) { + corpus := []testDoc{ + {1, "the quick brown fox jumps over the lazy dog"}, + {2, "quick fox"}, + {3, "quick quick quick fox"}, // High Term Frequency (TF) + {4, "completely irrelevant document here"}, + } + engine := NewBM25Engine(corpus, extractText) + + t.Run("Term Frequency (TF) boosts score", func(t *testing.T) { + results := engine.Search("quick", 5) + if len(results) < 3 { + t.Fatalf("expected at least 3 results, got %d", len(results)) + } + // Doc 3 has the word "quick" repeated 3 times, it should beat Doc 2 + if results[0].Document.ID != 3 { + t.Errorf("expected doc 3 to rank first due to high TF, got doc %d", results[0].Document.ID) + } + }) + + t.Run("Document Length penalty", func(t *testing.T) { + results := engine.Search("fox", 5) + if len(results) < 3 { + t.Fatalf("expected at least 3 results, got %d", len(results)) + } + // Doc 2 ("quick fox") is much shorter than Doc 1 ("the quick brown fox..."), + // so, with equal Term Frequency for the word "fox" (1 time), Doc 2 wins. + if results[0].Document.ID != 2 { + t.Errorf("expected doc 2 to rank first due to shorter length, got doc %d", results[0].Document.ID) + } + }) + + t.Run("TopK limits results", func(t *testing.T) { + results := engine.Search("quick", 2) + if len(results) != 2 { + t.Errorf("expected exactly 2 results, got %d", len(results)) + } + }) +} + +func TestBM25Tokenize(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {"Hello World", []string{"hello", "world"}}, + {" spaces everywhere ", []string{"spaces", "everywhere"}}, + {"punctuation... test!!!", []string{"punctuation", "test"}}, + {"(parentheses) and-hyphens", []string{"parentheses", "and-hyphens"}}, // hyphens trimmed from edges + {"internal-hyphen is kept", []string{"internal-hyphen", "is", "kept"}}, + {".,;?!", []string{}}, // Becomes empty after trim + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := bm25Tokenize(tt.input) + if len(got) == 0 && len(tt.expected) == 0 { + return // Both empty + } + if !reflect.DeepEqual(got, tt.expected) { + t.Errorf("bm25Tokenize(%q) = %v, want %v", tt.input, got, tt.expected) + } + }) + } +} + +func TestBM25Dedupe(t *testing.T) { + input := []string{"apple", "banana", "apple", "orange", "banana"} + expected := []string{"apple", "banana", "orange"} + + got := bm25Dedupe(input) + if !reflect.DeepEqual(got, expected) { + t.Errorf("bm25Dedupe() = %v, want %v", got, expected) + } +} + +func TestBM25Options(t *testing.T) { + corpus := []testDoc{{1, "test"}} + + engine := NewBM25Engine( + corpus, + extractText, + WithK1(2.5), + WithB(0.9), + ) + + if engine.k1 != 2.5 { + t.Errorf("expected k1 to be 2.5, got %v", engine.k1) + } + if engine.b != 0.9 { + t.Errorf("expected b to be 0.9, got %v", engine.b) + } +} + +func TestBM25Search_SortingStability(t *testing.T) { + // Ensure that sorting by heap returns in correct descending order + corpus := []testDoc{ + {1, "golang is good"}, + {2, "golang golang"}, + {3, "golang golang golang"}, + {4, "golang golang golang golang"}, + } + engine := NewBM25Engine(corpus, extractText) + results := engine.Search("golang", 10) + + if len(results) != 4 { + t.Fatalf("expected 4 results, got %d", len(results)) + } + + // Score should be strictly decreasing + for i := 1; i < len(results); i++ { + if results[i].Score > results[i-1].Score { + t.Errorf("results not sorted correctly: result %d score (%v) > result %d score (%v)", + i, results[i].Score, i-1, results[i-1].Score) + } + } +} diff --git a/pkg/utils/media.go b/pkg/utils/media.go index 31d888f79..3e1c5d88e 100644 --- a/pkg/utils/media.go +++ b/pkg/utils/media.go @@ -14,13 +14,11 @@ import ( "github.com/sipeed/picoclaw/pkg/logger" ) -var ( - audioExtensions = []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"} - audioTypes = []string{"audio/", "application/ogg", "application/x-ogg"} -) - // IsAudioFile checks if a file is an audio file based on its filename extension and content type. func IsAudioFile(filename, contentType string) bool { + audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"} + audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"} + for _, ext := range audioExtensions { if strings.HasSuffix(strings.ToLower(filename), ext) { return true diff --git a/pkg/utils/string.go b/pkg/utils/string.go index 4a3af779c..dbaafdb7f 100644 --- a/pkg/utils/string.go +++ b/pkg/utils/string.go @@ -1,98 +1,17 @@ package utils import ( - "regexp" "strings" + "sync/atomic" "unicode" ) -// Repetition detection constants. -const ( - repetitionSampleSize = 2000 // runes to sample from the tail - repetitionNgramSize = 10 // sliding window length - repetitionUniqueThreshold = 0.1 // unique ratio below this → repetition -) +// Global variable to disable truncation +var disableTruncation atomic.Bool -var ( - thinkBlockClosedRe = regexp.MustCompile(`(?is)<think>.*?</think>`) - thinkBlockOpenRe = regexp.MustCompile(`(?is)<think>.*$`) -) - -// StripThinkBlocks removes <think>…</think> blocks (including unclosed ones) -// from s and returns the trimmed result. -func StripThinkBlocks(s string) string { - s = thinkBlockClosedRe.ReplaceAllString(s, "") - s = thinkBlockOpenRe.ReplaceAllString(s, "") - return strings.TrimSpace(s) -} - -// TailPad returns a fixed-height block of n visual lines built from the -// tail of s. Long lines are wrapped at wrapWidth runes so the result -// never exceeds the chat bubble width. If fewer than n visual lines -// exist, Braille-blank lines (\u2800) are prepended as padding. -func TailPad(s string, n, wrapWidth int) string { - // Wrap each raw line into visual lines respecting wrapWidth. - var visual []string - for _, raw := range strings.Split(s, "\n") { - visual = append(visual, wrapLine(raw, wrapWidth)...) - } - if len(visual) > n { - visual = visual[len(visual)-n:] - } - for len(visual) < n { - visual = append([]string{"\u2800"}, visual...) - } - return strings.Join(visual, "\n") -} - -// wrapLine splits a single line into segments of at most width runes. -// An empty line produces one empty string (preserving blank lines). -func wrapLine(line string, width int) []string { - // ASCII fast path: byte length == rune length for pure ASCII - if len(line) <= width { - return []string{line} - } - runes := []rune(line) - if len(runes) <= width { - return []string{line} - } - var segs []string - for len(runes) > 0 { - end := width - if end > len(runes) { - end = len(runes) - } - segs = append(segs, string(runes[:end])) - runes = runes[end:] - } - return segs -} - -// DetectRepetitionLoop checks if text contains degenerate repetition -// by computing the unique N-gram ratio on the last repetitionSampleSize runes. -// Returns true if the ratio of unique N-grams to total N-grams -// falls below repetitionUniqueThreshold (i.e., 90%+ are duplicates). -func DetectRepetitionLoop(text string) bool { - runes := []rune(text) - - // Sample the tail - if len(runes) > repetitionSampleSize { - runes = runes[len(runes)-repetitionSampleSize:] - } - - total := len(runes) - repetitionNgramSize + 1 - if total <= 0 { - return false - } - - unique := make(map[string]struct{}, total/repetitionNgramSize) - for i := 0; i < total; i++ { - ng := string(runes[i : i+repetitionNgramSize]) - unique[ng] = struct{}{} - } - - ratio := float64(len(unique)) / float64(total) - return ratio < repetitionUniqueThreshold +// SetDisableTruncation globally enables or disables string truncation +func SetDisableTruncation(enabled bool) { + disableTruncation.Store(enabled) } // SanitizeMessageContent removes Unicode control characters, format characters (RTL overrides, @@ -100,9 +19,14 @@ func DetectRepetitionLoop(text string) bool { // or cause display issues in the agent UI. func SanitizeMessageContent(input string) string { var sb strings.Builder + // Pre-allocate memory to avoid multiple allocations sb.Grow(len(input)) for _, r := range input { + // unicode.IsGraphic returns true if the rune is a Unicode graphic character. + // This includes letters, marks, numbers, punctuation, and symbols. + // It excludes control characters (Cc), format characters (Cf), + // surrogates (Cs), and private use (Co). if unicode.IsGraphic(r) || r == '\n' || r == '\r' || r == '\t' { sb.WriteRune(r) } @@ -115,13 +39,13 @@ func SanitizeMessageContent(input string) string { // Handles multi-byte Unicode characters properly. // If the string is truncated, "..." is appended to indicate truncation. func Truncate(s string, maxLen int) string { + // If the no-truncate flag is active, it returns the full string + if disableTruncation.Load() { + return s + } if maxLen <= 0 { return "" } - // ASCII fast path: byte length == rune length for pure ASCII - if len(s) <= maxLen { - return s - } runes := []rune(s) if len(runes) <= maxLen { return s diff --git a/pkg/utils/string_ext.go b/pkg/utils/string_ext.go new file mode 100644 index 000000000..53aff0ec3 --- /dev/null +++ b/pkg/utils/string_ext.go @@ -0,0 +1,95 @@ +package utils + +import ( + "regexp" + "strings" +) + +// Repetition detection constants. +const ( + repetitionSampleSize = 2000 // runes to sample from the tail + repetitionNgramSize = 10 // sliding window length + repetitionUniqueThreshold = 0.1 // unique ratio below this → repetition +) + +var ( + thinkBlockClosedRe = regexp.MustCompile(`(?is)<think>.*?</think>`) + thinkBlockOpenRe = regexp.MustCompile(`(?is)<think>.*$`) +) + +// StripThinkBlocks removes <think>…</think> blocks (including unclosed ones) +// from s and returns the trimmed result. +func StripThinkBlocks(s string) string { + s = thinkBlockClosedRe.ReplaceAllString(s, "") + s = thinkBlockOpenRe.ReplaceAllString(s, "") + return strings.TrimSpace(s) +} + +// TailPad returns a fixed-height block of n visual lines built from the +// tail of s. Long lines are wrapped at wrapWidth runes so the result +// never exceeds the chat bubble width. If fewer than n visual lines +// exist, Braille-blank lines (\u2800) are prepended as padding. +func TailPad(s string, n, wrapWidth int) string { + // Wrap each raw line into visual lines respecting wrapWidth. + var visual []string + for _, raw := range strings.Split(s, "\n") { + visual = append(visual, wrapLine(raw, wrapWidth)...) + } + if len(visual) > n { + visual = visual[len(visual)-n:] + } + for len(visual) < n { + visual = append([]string{"\u2800"}, visual...) + } + return strings.Join(visual, "\n") +} + +// wrapLine splits a single line into segments of at most width runes. +// An empty line produces one empty string (preserving blank lines). +func wrapLine(line string, width int) []string { + // ASCII fast path: byte length == rune length for pure ASCII + if len(line) <= width { + return []string{line} + } + runes := []rune(line) + if len(runes) <= width { + return []string{line} + } + var segs []string + for len(runes) > 0 { + end := width + if end > len(runes) { + end = len(runes) + } + segs = append(segs, string(runes[:end])) + runes = runes[end:] + } + return segs +} + +// DetectRepetitionLoop checks if text contains degenerate repetition +// by computing the unique N-gram ratio on the last repetitionSampleSize runes. +// Returns true if the ratio of unique N-grams to total N-grams +// falls below repetitionUniqueThreshold (i.e., 90%+ are duplicates). +func DetectRepetitionLoop(text string) bool { + runes := []rune(text) + + // Sample the tail + if len(runes) > repetitionSampleSize { + runes = runes[len(runes)-repetitionSampleSize:] + } + + total := len(runes) - repetitionNgramSize + 1 + if total <= 0 { + return false + } + + unique := make(map[string]struct{}, total/repetitionNgramSize) + for i := 0; i < total; i++ { + ng := string(runes[i : i+repetitionNgramSize]) + unique[ng] = struct{}{} + } + + ratio := float64(len(unique)) / float64(total) + return ratio < repetitionUniqueThreshold +} diff --git a/pkg/utils/string_ext_test.go b/pkg/utils/string_ext_test.go new file mode 100644 index 000000000..40018adad --- /dev/null +++ b/pkg/utils/string_ext_test.go @@ -0,0 +1,172 @@ +package utils + +import ( + "strings" + "testing" +) + +func TestStripThinkBlocks_ClosedBlock(t *testing.T) { + in := "<think>\nsecret reasoning\n</think>\n\nVisible content" + got := StripThinkBlocks(in) + if got != "Visible content" { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, "Visible content") + } +} + +func TestStripThinkBlocks_UnclosedBlock(t *testing.T) { + in := "<think>reasoning that never ends\nmore reasoning" + got := StripThinkBlocks(in) + if got != "" { + t.Fatalf("StripThinkBlocks() = %q, want empty", got) + } +} + +func TestStripThinkBlocks_MultipleBlocks(t *testing.T) { + in := "<think>first</think>middle<think>second</think>end" + got := StripThinkBlocks(in) + if got != "middleend" { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middleend") + } +} + +func TestStripThinkBlocks_NoBlocks(t *testing.T) { + in := "plain text without think blocks" + got := StripThinkBlocks(in) + if got != in { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, in) + } +} + +func TestStripThinkBlocks_CaseInsensitive(t *testing.T) { + in := "<THINK>upper case</THINK>visible" + got := StripThinkBlocks(in) + if got != "visible" { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, "visible") + } +} + +func TestStripThinkBlocks_ClosedThenUnclosed(t *testing.T) { + in := "<think>closed</think>middle<think>unclosed tail" + got := StripThinkBlocks(in) + if got != "middle" { + t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middle") + } +} + +func TestDetectRepetitionLoop_HighRepetition(t *testing.T) { + phrase := "結構本格的なコード" //nolint:gosmopolitan + repeated := strings.Repeat(phrase, 300) + if !DetectRepetitionLoop(repeated) { + t.Fatal("DetectRepetitionLoop should return true for highly repetitive text") + } +} + +func TestDetectRepetitionLoop_NormalText(t *testing.T) { + normal := "The quick brown fox jumps over the lazy dog. " + + "Pack my box with five dozen liquor jugs. " + + "How vexingly quick daft zebras jump. " + + "Sphinx of black quartz, judge my vow. " + + "Two driven jocks help fax my big quiz. " + + "The five boxing wizards jump quickly. " + + "Jackdaws love my big sphinx of quartz. " + + "Grumpy wizards make a toxic brew for the jovial queen." + + long := strings.Repeat(normal+" ", 10) + if DetectRepetitionLoop(long) { + t.Fatal("DetectRepetitionLoop should return false for normal text") + } +} + +func TestDetectRepetitionLoop_ShortText(t *testing.T) { + if DetectRepetitionLoop("short") { + t.Fatal("DetectRepetitionLoop should return false for short text") + } +} + +func TestDetectRepetitionLoop_EmptyString(t *testing.T) { + if DetectRepetitionLoop("") { + t.Fatal("DetectRepetitionLoop should return false for empty string") + } +} + +func TestDetectRepetitionLoop_SingleCharRepeat(t *testing.T) { + repeated := strings.Repeat("あ", 2500) + if !DetectRepetitionLoop(repeated) { + t.Fatal("DetectRepetitionLoop should return true for single-char repetition") + } +} + +func TestDetectRepetitionLoop_BelowSampleSize(t *testing.T) { + phrase := "abcdefghij" + repeated := strings.Repeat(phrase, 50) + if !DetectRepetitionLoop(repeated) { + t.Fatal("DetectRepetitionLoop should return true for repetitive text below sample size") + } +} + +func TestTailPad_FewerThanN(t *testing.T) { + got := TailPad("a\nb", 5, 80) + lines := strings.Split(got, "\n") + if len(lines) != 5 { + t.Fatalf("TailPad line count = %d, want 5", len(lines)) + } + for i := 0; i < 3; i++ { + if lines[i] != "\u2800" { + t.Errorf("TailPad line %d = %q, want padding", i, lines[i]) + } + } + if lines[3] != "a" || lines[4] != "b" { + t.Errorf("TailPad content = %q %q, want a b", lines[3], lines[4]) + } +} + +func TestTailPad_ExactlyN(t *testing.T) { + in := "a\nb\nc" + got := TailPad(in, 3, 80) + if got != in { + t.Fatalf("TailPad exact = %q, want %q", got, in) + } +} + +func TestTailPad_MoreThanN(t *testing.T) { + got := TailPad("a\nb\nc\nd\ne", 3, 80) + if got != "c\nd\ne" { + t.Fatalf("TailPad tail = %q, want %q", got, "c\nd\ne") + } +} + +func TestTailPad_Empty(t *testing.T) { + got := TailPad("", 4, 80) + lines := strings.Split(got, "\n") + if len(lines) != 4 { + t.Fatalf("TailPad empty line count = %d, want 4", len(lines)) + } + for i, l := range lines { + if i == len(lines)-1 { + if l != "" { + t.Errorf("TailPad empty last line = %q, want empty", l) + } + } else if l != "\u2800" { + t.Errorf("TailPad empty line %d = %q, want padding", i, l) + } + } +} + +func TestTailPad_LongLineWraps(t *testing.T) { + got := TailPad("abcdefghij", 4, 5) + lines := strings.Split(got, "\n") + if len(lines) != 4 { + t.Fatalf("TailPad wrap line count = %d, want 4", len(lines)) + } + + if lines[2] != "abcde" || lines[3] != "fghij" { + t.Errorf("TailPad wrap content = %v", lines) + } +} + +func TestTailPad_WrapPushesOldLines(t *testing.T) { + got := TailPad("short\nabcdefghij", 2, 5) + if got != "abcde\nfghij" { + t.Fatalf("TailPad wrap push = %q, want %q", got, "abcde\nfghij") + } +} diff --git a/pkg/utils/string_test.go b/pkg/utils/string_test.go index 7b4b54098..e3b5af052 100644 --- a/pkg/utils/string_test.go +++ b/pkg/utils/string_test.go @@ -1,191 +1,6 @@ package utils -import ( - "strings" - "testing" -) - -// --- StripThinkBlocks --- - -func TestStripThinkBlocks_ClosedBlock(t *testing.T) { - in := "<think>\nsecret reasoning\n</think>\n\nVisible content" - got := StripThinkBlocks(in) - if got != "Visible content" { - t.Fatalf("StripThinkBlocks() = %q, want %q", got, "Visible content") - } -} - -func TestStripThinkBlocks_UnclosedBlock(t *testing.T) { - in := "<think>reasoning that never ends\nmore reasoning" - got := StripThinkBlocks(in) - if got != "" { - t.Fatalf("StripThinkBlocks() = %q, want empty", got) - } -} - -func TestStripThinkBlocks_MultipleBlocks(t *testing.T) { - in := "<think>first</think>middle<think>second</think>end" - got := StripThinkBlocks(in) - if got != "middleend" { - t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middleend") - } -} - -func TestStripThinkBlocks_NoBlocks(t *testing.T) { - in := "plain text without think blocks" - got := StripThinkBlocks(in) - if got != in { - t.Fatalf("StripThinkBlocks() = %q, want %q", got, in) - } -} - -func TestStripThinkBlocks_CaseInsensitive(t *testing.T) { - in := "<THINK>upper case</THINK>visible" - got := StripThinkBlocks(in) - if got != "visible" { - t.Fatalf("StripThinkBlocks() = %q, want %q", got, "visible") - } -} - -func TestStripThinkBlocks_ClosedThenUnclosed(t *testing.T) { - in := "<think>closed</think>middle<think>unclosed tail" - got := StripThinkBlocks(in) - if got != "middle" { - t.Fatalf("StripThinkBlocks() = %q, want %q", got, "middle") - } -} - -// --- DetectRepetitionLoop --- - -func TestDetectRepetitionLoop_HighRepetition(t *testing.T) { - // Repeat a short phrase many times → should be detected - phrase := "結構本格的なコード" //nolint:gosmopolitan // CJK test data - repeated := strings.Repeat(phrase, 300) - if !DetectRepetitionLoop(repeated) { - t.Fatal("DetectRepetitionLoop should return true for highly repetitive text") - } -} - -func TestDetectRepetitionLoop_NormalText(t *testing.T) { - // Normal varied text should not trigger - normal := "The quick brown fox jumps over the lazy dog. " + - "Pack my box with five dozen liquor jugs. " + - "How vexingly quick daft zebras jump. " + - "Sphinx of black quartz, judge my vow. " + - "Two driven jocks help fax my big quiz. " + - "The five boxing wizards jump quickly. " + - "Jackdaws love my big sphinx of quartz. " + - "Grumpy wizards make a toxic brew for the jovial queen." - // Extend to be long enough - long := strings.Repeat(normal+" ", 10) - if DetectRepetitionLoop(long) { - t.Fatal("DetectRepetitionLoop should return false for normal text") - } -} - -func TestDetectRepetitionLoop_ShortText(t *testing.T) { - // Text shorter than N-gram size should never trigger - if DetectRepetitionLoop("short") { - t.Fatal("DetectRepetitionLoop should return false for short text") - } -} - -func TestDetectRepetitionLoop_EmptyString(t *testing.T) { - if DetectRepetitionLoop("") { - t.Fatal("DetectRepetitionLoop should return false for empty string") - } -} - -func TestDetectRepetitionLoop_SingleCharRepeat(t *testing.T) { - // "aaaa..." repeated → only 1 unique N-gram → detected - repeated := strings.Repeat("あ", 2500) - if !DetectRepetitionLoop(repeated) { - t.Fatal("DetectRepetitionLoop should return true for single-char repetition") - } -} - -func TestDetectRepetitionLoop_BelowSampleSize(t *testing.T) { - // Repetitive but under sample size still detected - phrase := "abcdefghij" - repeated := strings.Repeat(phrase, 50) // 500 chars - if !DetectRepetitionLoop(repeated) { - t.Fatal("DetectRepetitionLoop should return true for repetitive text below sample size") - } -} - -// --- TailPad --- - -func TestTailPad_FewerThanN(t *testing.T) { - got := TailPad("a\nb", 5, 80) - lines := strings.Split(got, "\n") - if len(lines) != 5 { - t.Fatalf("TailPad line count = %d, want 5", len(lines)) - } - for i := 0; i < 3; i++ { - if lines[i] != "\u2800" { - t.Errorf("TailPad line %d = %q, want padding", i, lines[i]) - } - } - if lines[3] != "a" || lines[4] != "b" { - t.Errorf("TailPad content = %q %q, want a b", lines[3], lines[4]) - } -} - -func TestTailPad_ExactlyN(t *testing.T) { - in := "a\nb\nc" - got := TailPad(in, 3, 80) - if got != in { - t.Fatalf("TailPad exact = %q, want %q", got, in) - } -} - -func TestTailPad_MoreThanN(t *testing.T) { - got := TailPad("a\nb\nc\nd\ne", 3, 80) - if got != "c\nd\ne" { - t.Fatalf("TailPad tail = %q, want %q", got, "c\nd\ne") - } -} - -func TestTailPad_Empty(t *testing.T) { - got := TailPad("", 4, 80) - lines := strings.Split(got, "\n") - if len(lines) != 4 { - t.Fatalf("TailPad empty line count = %d, want 4", len(lines)) - } - for i, l := range lines { - if i == len(lines)-1 { - if l != "" { - t.Errorf("TailPad empty last line = %q, want empty", l) - } - } else if l != "\u2800" { - t.Errorf("TailPad empty line %d = %q, want padding", i, l) - } - } -} - -func TestTailPad_LongLineWraps(t *testing.T) { - // One 10-char line wraps into 2 visual lines at width 5. - got := TailPad("abcdefghij", 4, 5) - lines := strings.Split(got, "\n") - if len(lines) != 4 { - t.Fatalf("TailPad wrap line count = %d, want 4", len(lines)) - } - // 2 padding + "abcde" + "fghij" - if lines[2] != "abcde" || lines[3] != "fghij" { - t.Errorf("TailPad wrap content = %v", lines) - } -} - -func TestTailPad_WrapPushesOldLines(t *testing.T) { - // "short" (1 visual) + "abcdefghij" (2 visual at width 5) = 3 visual. - // With n=2, only tail 2 visual lines remain. - got := TailPad("short\nabcdefghij", 2, 5) - if got != "abcde\nfghij" { - t.Fatalf("TailPad wrap push = %q, want %q", got, "abcde\nfghij") - } -} - -// --- Truncate --- +import "testing" func TestTruncate(t *testing.T) { tests := []struct { diff --git a/web/Makefile b/web/Makefile new file mode 100644 index 000000000..559005956 --- /dev/null +++ b/web/Makefile @@ -0,0 +1,38 @@ +.PHONY: dev dev-frontend dev-backend build test lint clean + +# Run both frontend and backend dev servers +dev: + @if [ ! -f backend/picoclaw-web ] || [ ! -d backend/dist ]; then \ + echo "Build artifacts not found, building..."; \ + $(MAKE) build; \ + fi + @echo "Starting backend and frontend dev servers..." + @$(MAKE) dev-backend & $(MAKE) dev-frontend + +# Start frontend dev server (Vite, with proxy to backend) +dev-frontend: + cd frontend && pnpm dev + +# Start backend dev server +dev-backend: + cd backend && go run . + +# Build frontend and embed into Go binary +build: + cd frontend && pnpm build:backend + cd backend && go build -o picoclaw-web . + +# Run all tests +test: + cd backend && go test ./... + cd frontend && pnpm lint + +# Lint and format +lint: + cd backend && go vet ./... + cd frontend && pnpm check + +# Clean build artifacts +clean: + rm -rf frontend/dist backend/dist backend/picoclaw-web + mkdir -p backend/dist && touch backend/dist/.gitkeep diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..6ec247bae --- /dev/null +++ b/web/README.md @@ -0,0 +1,51 @@ +# Picoclaw Web + +This directory contains the standalone web service for `picoclaw`. +It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine. + +## Architecture + +The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment. + +* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable. +* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface. + +## Getting Started + +### Prerequisites + +* Go 1.25+ +* Node.js 20+ with pnpm + +### Development + +Run both the frontend dev server and the Go backend simultaneously: + +```bash +make dev +``` + +Or run them separately: + +```bash +make dev-frontend # Vite dev server +make dev-backend # Go backend +``` + +### Build + +Build the frontend and embed it into a single Go binary: + +```bash +make build +``` + +The output binary is `backend/picoclaw-web`. + +### Other Commands + +```bash +make test # Run backend tests and frontend lint +make lint # Run go vet and prettier/eslint +make clean # Remove all build artifacts +``` diff --git a/web/backend/.gitignore b/web/backend/.gitignore new file mode 100644 index 000000000..509042171 --- /dev/null +++ b/web/backend/.gitignore @@ -0,0 +1,19 @@ +# Go build output +*.exe +*.dll +*.so +*.dylib +*.test +*.out +picoclaw-web + +# Frontend build artifacts (embedded by Go) +dist/* +!dist/.gitkeep + +# OS +.DS_Store + +# Editors +.vscode/ +.idea/ \ No newline at end of file diff --git a/web/backend/api/channels.go b/web/backend/api/channels.go new file mode 100644 index 000000000..507882823 --- /dev/null +++ b/web/backend/api/channels.go @@ -0,0 +1,47 @@ +package api + +import ( + "encoding/json" + "net/http" +) + +type channelCatalogItem struct { + Name string `json:"name"` + ConfigKey string `json:"config_key"` + Variant string `json:"variant,omitempty"` +} + +var channelCatalog = []channelCatalogItem{ + {Name: "telegram", ConfigKey: "telegram"}, + {Name: "discord", ConfigKey: "discord"}, + {Name: "slack", ConfigKey: "slack"}, + {Name: "feishu", ConfigKey: "feishu"}, + {Name: "dingtalk", ConfigKey: "dingtalk"}, + {Name: "line", ConfigKey: "line"}, + {Name: "qq", ConfigKey: "qq"}, + {Name: "onebot", ConfigKey: "onebot"}, + {Name: "wecom", ConfigKey: "wecom"}, + {Name: "wecom_app", ConfigKey: "wecom_app"}, + {Name: "wecom_aibot", ConfigKey: "wecom_aibot"}, + {Name: "whatsapp", ConfigKey: "whatsapp", Variant: "bridge"}, + {Name: "whatsapp_native", ConfigKey: "whatsapp", Variant: "native"}, + {Name: "pico", ConfigKey: "pico"}, + {Name: "maixcam", ConfigKey: "maixcam"}, + {Name: "matrix", ConfigKey: "matrix"}, + {Name: "irc", ConfigKey: "irc"}, +} + +// registerChannelRoutes binds read-only channel catalog endpoints to the ServeMux. +func (h *Handler) registerChannelRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/channels/catalog", h.handleListChannelCatalog) +} + +// handleListChannelCatalog returns the channels supported by backend. +// +// GET /api/channels/catalog +func (h *Handler) handleListChannelCatalog(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "channels": channelCatalog, + }) +} diff --git a/web/backend/api/config.go b/web/backend/api/config.go new file mode 100644 index 000000000..091e3fbae --- /dev/null +++ b/web/backend/api/config.go @@ -0,0 +1,212 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// registerConfigRoutes binds configuration management endpoints to the ServeMux. +func (h *Handler) registerConfigRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/config", h.handleGetConfig) + mux.HandleFunc("PUT /api/config", h.handleUpdateConfig) + mux.HandleFunc("PATCH /api/config", h.handlePatchConfig) +} + +// handleGetConfig returns the complete system configuration. +// +// GET /api/config +func (h *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(cfg); err != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +// handleUpdateConfig updates the complete system configuration. +// +// PUT /api/config +func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var cfg config.Config + if err := json.Unmarshal(body, &cfg); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + if execAllowRemoteOmitted(body) { + cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote + } + + if errs := validateConfig(&cfg); len(errs) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "validation_error", + "errors": errs, + }) + return + } + + if err := config.SaveConfig(h.configPath, &cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func execAllowRemoteOmitted(body []byte) bool { + var raw struct { + Tools *struct { + Exec *struct { + AllowRemote *bool `json:"allow_remote"` + } `json:"exec"` + } `json:"tools"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return false + } + return raw.Tools == nil || raw.Tools.Exec == nil || raw.Tools.Exec.AllowRemote == nil +} + +// handlePatchConfig partially updates the system configuration using JSON Merge Patch (RFC 7396). +// Only the fields present in the request body will be updated; all other fields remain unchanged. +// +// PATCH /api/config +func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) { + patchBody, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + // Validate the patch is valid JSON + var patch map[string]any + if err = json.Unmarshal(patchBody, &patch); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + // Load existing config and marshal to a map for merging + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + existing, err := json.Marshal(cfg) + if err != nil { + http.Error(w, "Failed to serialize current config", http.StatusInternalServerError) + return + } + + var base map[string]any + if err = json.Unmarshal(existing, &base); err != nil { + http.Error(w, "Failed to parse current config", http.StatusInternalServerError) + return + } + + // Recursively merge patch into base + mergeMap(base, patch) + + // Convert merged map back to Config struct + merged, err := json.Marshal(base) + if err != nil { + http.Error(w, "Failed to serialize merged config", http.StatusInternalServerError) + return + } + + var newCfg config.Config + if err := json.Unmarshal(merged, &newCfg); err != nil { + http.Error(w, fmt.Sprintf("Merged config is invalid: %v", err), http.StatusBadRequest) + return + } + + if errs := validateConfig(&newCfg); len(errs) > 0 { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "validation_error", + "errors": errs, + }) + return + } + + if err := config.SaveConfig(h.configPath, &newCfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// validateConfig checks the config for common errors before saving. +// Returns a list of human-readable error strings; empty means valid. +func validateConfig(cfg *config.Config) []string { + var errs []string + + // Validate model_list entries + if err := cfg.ValidateModelList(); err != nil { + errs = append(errs, err.Error()) + } + + // Gateway port range + if cfg.Gateway.Port != 0 && (cfg.Gateway.Port < 1 || cfg.Gateway.Port > 65535) { + errs = append(errs, fmt.Sprintf("gateway.port %d is out of valid range (1-65535)", cfg.Gateway.Port)) + } + + // Pico channel: token required when enabled + if cfg.Channels.Pico.Enabled && cfg.Channels.Pico.Token == "" { + errs = append(errs, "channels.pico.token is required when pico channel is enabled") + } + + // Telegram: token required when enabled + if cfg.Channels.Telegram.Enabled && cfg.Channels.Telegram.Token == "" { + errs = append(errs, "channels.telegram.token is required when telegram channel is enabled") + } + + // Discord: token required when enabled + if cfg.Channels.Discord.Enabled && cfg.Channels.Discord.Token == "" { + errs = append(errs, "channels.discord.token is required when discord channel is enabled") + } + + return errs +} + +// mergeMap recursively merges src into dst (JSON Merge Patch semantics). +// - If a key in src has a null value, it is deleted from dst. +// - If both dst and src have a nested object for the same key, merge recursively. +// - Otherwise the value from src overwrites dst. +func mergeMap(dst, src map[string]any) { + for key, srcVal := range src { + if srcVal == nil { + delete(dst, key) + continue + } + srcMap, srcIsMap := srcVal.(map[string]any) + dstMap, dstIsMap := dst[key].(map[string]any) + if srcIsMap && dstIsMap { + mergeMap(dstMap, srcMap) + } else { + dst[key] = srcVal + } + } +} diff --git a/web/backend/api/config_test.go b/web/backend/api/config_test.go new file mode 100644 index 000000000..29811e37e --- /dev/null +++ b/web/backend/api/config_test.go @@ -0,0 +1,88 @@ +package api + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleUpdateConfig_PreservesExecAllowRemoteDefaultWhenOmitted(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_key": "sk-default" + } + ] + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if !cfg.Tools.Exec.AllowRemote { + t.Fatal("tools.exec.allow_remote should remain true when omitted from PUT /api/config") + } +} + +func TestHandleUpdateConfig_DoesNotInheritDefaultModelFields(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + req := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewBufferString(`{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace" + } + }, + "model_list": [ + { + "model_name": "custom-default", + "model": "openai/gpt-4o", + "api_key": "sk-default" + } + ] + }`)) + req.Header.Set("Content-Type", "application/json") + + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + if got := cfg.ModelList[0].APIBase; got != "" { + t.Fatalf("model_list[0].api_base = %q, want empty string", got) + } +} diff --git a/web/backend/api/events.go b/web/backend/api/events.go new file mode 100644 index 000000000..0a8d4a9bb --- /dev/null +++ b/web/backend/api/events.go @@ -0,0 +1,62 @@ +package api + +import ( + "encoding/json" + "sync" +) + +// GatewayEvent represents a state change event for the gateway process. +type GatewayEvent struct { + Status string `json:"gateway_status"` // "running", "starting", "stopped", "error" + PID int `json:"pid,omitempty"` +} + +// EventBroadcaster manages SSE client subscriptions and broadcasts events. +type EventBroadcaster struct { + mu sync.RWMutex + clients map[chan string]struct{} +} + +// NewEventBroadcaster creates a new broadcaster. +func NewEventBroadcaster() *EventBroadcaster { + return &EventBroadcaster{ + clients: make(map[chan string]struct{}), + } +} + +// Subscribe adds a new listener channel and returns it. +// The caller must call Unsubscribe when done. +func (b *EventBroadcaster) Subscribe() chan string { + ch := make(chan string, 8) + b.mu.Lock() + b.clients[ch] = struct{}{} + b.mu.Unlock() + return ch +} + +// Unsubscribe removes a listener channel and closes it. +func (b *EventBroadcaster) Unsubscribe(ch chan string) { + b.mu.Lock() + delete(b.clients, ch) + b.mu.Unlock() + close(ch) +} + +// Broadcast sends a GatewayEvent to all connected SSE clients. +func (b *EventBroadcaster) Broadcast(event GatewayEvent) { + data, err := json.Marshal(event) + if err != nil { + return + } + + b.mu.RLock() + defer b.mu.RUnlock() + + for ch := range b.clients { + // Non-blocking send; drop event if client is slow + select { + case ch <- string(data): + default: + } + } +} diff --git a/web/backend/api/gateway.go b/web/backend/api/gateway.go new file mode 100644 index 000000000..41f702e32 --- /dev/null +++ b/web/backend/api/gateway.go @@ -0,0 +1,560 @@ +package api + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +// gateway holds the state for the managed gateway process. +var gateway = struct { + mu sync.Mutex + cmd *exec.Cmd + logs *LogBuffer + events *EventBroadcaster +}{ + logs: NewLogBuffer(200), + events: NewEventBroadcaster(), +} + +// registerGatewayRoutes binds gateway lifecycle endpoints to the ServeMux. +func (h *Handler) registerGatewayRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/gateway/status", h.handleGatewayStatus) + mux.HandleFunc("GET /api/gateway/events", h.handleGatewayEvents) + mux.HandleFunc("POST /api/gateway/logs/clear", h.handleGatewayClearLogs) + mux.HandleFunc("POST /api/gateway/start", h.handleGatewayStart) + mux.HandleFunc("POST /api/gateway/stop", h.handleGatewayStop) + mux.HandleFunc("POST /api/gateway/restart", h.handleGatewayRestart) +} + +// TryAutoStartGateway checks whether gateway start preconditions are met and +// starts it when possible. Intended to be called by the backend at startup. +func (h *Handler) TryAutoStartGateway() { + gateway.mu.Lock() + defer gateway.mu.Unlock() + + if isGatewayProcessAliveLocked() { + return + } + if gateway.cmd != nil && gateway.cmd.Process != nil { + gateway.cmd = nil + } + + ready, reason, err := h.gatewayStartReady() + if err != nil { + log.Printf("Skip auto-starting gateway: %v", err) + return + } + if !ready { + log.Printf("Skip auto-starting gateway: %s", reason) + return + } + + pid, err := h.startGatewayLocked() + if err != nil { + log.Printf("Failed to auto-start gateway: %v", err) + return + } + log.Printf("Gateway auto-started (PID: %d)", pid) +} + +// gatewayStartReady validates whether current config can start the gateway. +func (h *Handler) gatewayStartReady() (bool, string, error) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return false, "", fmt.Errorf("failed to load config: %w", err) + } + + modelName := strings.TrimSpace(cfg.Agents.Defaults.GetModelName()) + if modelName == "" { + return false, "no default model configured", nil + } + + modelCfg := lookupModelConfig(cfg, modelName) + if modelCfg == nil { + return false, fmt.Sprintf("default model %q is invalid", modelName), nil + } + + if !hasModelConfiguration(*modelCfg) { + return false, fmt.Sprintf("default model %q has no credentials configured", modelName), nil + } + if requiresRuntimeProbe(*modelCfg) && !probeLocalModelAvailability(*modelCfg) { + return false, fmt.Sprintf("default model %q is not reachable", modelName), nil + } + + return true, "", nil +} + +func lookupModelConfig(cfg *config.Config, modelName string) *config.ModelConfig { + modelCfg, err := cfg.GetModelConfig(modelName) + if err != nil { + return nil + } + return modelCfg +} + +func isGatewayProcessAliveLocked() bool { + return isCmdProcessAliveLocked(gateway.cmd) +} + +func isCmdProcessAliveLocked(cmd *exec.Cmd) bool { + if cmd == nil || cmd.Process == nil { + return false + } + + // Wait() sets ProcessState when the process exits; use it when available. + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + return false + } + + // Windows does not support Signal(0) probing. If we still own cmd and it + // has not reported exit, treat it as alive. + if runtime.GOOS == "windows" { + return true + } + + return cmd.Process.Signal(syscall.Signal(0)) == nil +} + +func (h *Handler) startGatewayLocked() (int, error) { + // Locate the picoclaw executable + execPath := utils.FindPicoclawBinary() + + cmd := exec.Command(execPath, "gateway") + cmd.Env = os.Environ() + // Forward the launcher's config path via the environment variable that + // GetConfigPath() already reads, so the gateway sub-process uses the same + // config file without requiring a --config flag on the gateway subcommand. + if h.configPath != "" { + cmd.Env = append(cmd.Env, "PICOCLAW_CONFIG="+h.configPath) + } + if host := h.gatewayHostOverride(); host != "" { + cmd.Env = append(cmd.Env, "PICOCLAW_GATEWAY_HOST="+host) + } + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return 0, fmt.Errorf("failed to create stdout pipe: %w", err) + } + + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return 0, fmt.Errorf("failed to create stderr pipe: %w", err) + } + + // Clear old logs for this new run + gateway.logs.Reset() + + // Ensure Pico Channel is configured before starting gateway + if _, err := h.ensurePicoChannel(); err != nil { + log.Printf("Warning: failed to ensure pico channel: %v", err) + // Non-fatal: gateway can still start without pico channel + } + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("failed to start gateway: %w", err) + } + + gateway.cmd = cmd + pid := cmd.Process.Pid + log.Printf("Started picoclaw gateway (PID: %d) from %s", pid, execPath) + + // Broadcast starting event + gateway.events.Broadcast(GatewayEvent{Status: "starting", PID: pid}) + + // Capture stdout/stderr in background + go scanPipe(stdoutPipe, gateway.logs) + go scanPipe(stderrPipe, gateway.logs) + + // Wait for exit in background and clean up + go func() { + if err := cmd.Wait(); err != nil { + log.Printf("Gateway process exited: %v", err) + } else { + log.Printf("Gateway process exited normally") + } + + gateway.mu.Lock() + if gateway.cmd == cmd { + gateway.cmd = nil + } + gateway.mu.Unlock() + + // Broadcast stopped event + gateway.events.Broadcast(GatewayEvent{Status: "stopped"}) + }() + + // Start a goroutine to probe health and broadcast "running" once ready + go func() { + for i := 0; i < 30; i++ { // try for up to 15 seconds + time.Sleep(500 * time.Millisecond) + gateway.mu.Lock() + stillOurs := gateway.cmd == cmd + gateway.mu.Unlock() + if !stillOurs { + return + } + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + continue + } + healthHost := gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) + healthPort := cfg.Gateway.Port + if healthPort == 0 { + healthPort = 18790 + } + healthURL := fmt.Sprintf("http://%s/health", net.JoinHostPort(healthHost, strconv.Itoa(healthPort))) + client := http.Client{Timeout: 1 * time.Second} + resp, err := client.Get(healthURL) + if err == nil { + resp.Body.Close() + if resp.StatusCode == http.StatusOK { + gateway.events.Broadcast(GatewayEvent{Status: "running", PID: pid}) + return + } + } + } + }() + + return pid, nil +} + +// handleGatewayStart starts the picoclaw gateway subprocess. +// +// POST /api/gateway/start +func (h *Handler) handleGatewayStart(w http.ResponseWriter, r *http.Request) { + gateway.mu.Lock() + defer gateway.mu.Unlock() + + // Prevent duplicate starts + if isGatewayProcessAliveLocked() { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + json.NewEncoder(w).Encode(map[string]any{ + "status": "already_running", + "pid": gateway.cmd.Process.Pid, + }) + return + } + if gateway.cmd != nil && gateway.cmd.Process != nil { + gateway.cmd = nil + } + + ready, reason, err := h.gatewayStartReady() + if err != nil { + http.Error( + w, + fmt.Sprintf("Failed to validate gateway start conditions: %v", err), + http.StatusInternalServerError, + ) + return + } + if !ready { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]any{ + "status": "precondition_failed", + "message": reason, + }) + return + } + + pid, err := h.startGatewayLocked() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": pid, + }) +} + +// handleGatewayStop stops the running gateway subprocess gracefully. +// +// POST /api/gateway/stop +func (h *Handler) handleGatewayStop(w http.ResponseWriter, r *http.Request) { + gateway.mu.Lock() + defer gateway.mu.Unlock() + + if gateway.cmd == nil || gateway.cmd.Process == nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "not_running", + }) + return + } + + pid := gateway.cmd.Process.Pid + + // Send SIGTERM for graceful shutdown (SIGKILL on Windows) + var sigErr error + if runtime.GOOS == "windows" { + sigErr = gateway.cmd.Process.Kill() + } else { + sigErr = gateway.cmd.Process.Signal(syscall.SIGTERM) + } + + if sigErr != nil { + http.Error(w, fmt.Sprintf("Failed to stop gateway (PID %d): %v", pid, sigErr), http.StatusInternalServerError) + return + } + + log.Printf("Sent stop signal to gateway (PID: %d)", pid) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "pid": pid, + }) +} + +// handleGatewayRestart stops the gateway (if running) and starts a new instance. +// +// POST /api/gateway/restart +func (h *Handler) handleGatewayRestart(w http.ResponseWriter, r *http.Request) { + gateway.mu.Lock() + + // Stop existing process if running + if gateway.cmd != nil && gateway.cmd.Process != nil { + if isCmdProcessAliveLocked(gateway.cmd) { + // Process is alive, send SIGTERM + if runtime.GOOS == "windows" { + gateway.cmd.Process.Kill() + } else { + gateway.cmd.Process.Signal(syscall.SIGTERM) + } + + // Wait briefly for it to exit + gateway.mu.Unlock() + time.Sleep(2 * time.Second) + gateway.mu.Lock() + } + gateway.cmd = nil + } + + gateway.mu.Unlock() + + // Start fresh via the existing handler + h.handleGatewayStart(w, r) +} + +// handleGatewayClearLogs clears the in-memory gateway log buffer. +// +// POST /api/gateway/logs/clear +func (h *Handler) handleGatewayClearLogs(w http.ResponseWriter, r *http.Request) { + gateway.logs.Clear() + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "cleared", + "log_total": 0, + "log_run_id": gateway.logs.RunID(), + }) +} + +// handleGatewayStatus returns the gateway run status, health info, and logs. +// +// GET /api/gateway/status +func (h *Handler) handleGatewayStatus(w http.ResponseWriter, r *http.Request) { + data := map[string]any{} + + // Check process state + gateway.mu.Lock() + processAlive := isGatewayProcessAliveLocked() + if processAlive { + data["pid"] = gateway.cmd.Process.Pid + } + gateway.mu.Unlock() + + if !processAlive { + data["gateway_status"] = "stopped" + } else { + // Process is alive — probe its health endpoint + cfg, err := config.LoadConfig(h.configPath) + host := "127.0.0.1" + port := 18790 + if err == nil && cfg != nil { + host = gatewayProbeHost(h.effectiveGatewayBindHost(cfg)) + if cfg.Gateway.Port != 0 { + port = cfg.Gateway.Port + } + } + + url := fmt.Sprintf("http://%s/health", net.JoinHostPort(host, strconv.Itoa(port))) + client := http.Client{Timeout: 2 * time.Second} + resp, err := client.Get(url) + + if err != nil { + data["gateway_status"] = "starting" + } else { + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + data["gateway_status"] = "error" + data["status_code"] = resp.StatusCode + } else { + var healthData map[string]any + if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil { + data["gateway_status"] = "error" + } else { + for k, v := range healthData { + data[k] = v + } + data["gateway_status"] = "running" + } + } + } + } + + ready, reason, readyErr := h.gatewayStartReady() + if readyErr != nil { + data["gateway_start_allowed"] = false + data["gateway_start_reason"] = readyErr.Error() + } else { + data["gateway_start_allowed"] = ready + if !ready { + data["gateway_start_reason"] = reason + } + } + + // Append incremental log data + appendGatewayLogs(r, data) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} + +// appendGatewayLogs reads log_offset and log_run_id query params from the request +// and populates the response data map with incremental log lines. +func appendGatewayLogs(r *http.Request, data map[string]any) { + clientOffset := 0 + clientRunID := -1 + + if v := r.URL.Query().Get("log_offset"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + clientOffset = n + } + } + + if v := r.URL.Query().Get("log_run_id"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + clientRunID = n + } + } + + runID := gateway.logs.RunID() + + if runID == 0 { + data["logs"] = []string{} + data["log_total"] = 0 + data["log_run_id"] = 0 + return + } + + // If runID changed, reset offset to get all logs from new run + offset := clientOffset + if clientRunID != runID { + offset = 0 + } + + lines, total, runID := gateway.logs.LinesSince(offset) + if lines == nil { + lines = []string{} + } + + data["logs"] = lines + data["log_total"] = total + data["log_run_id"] = runID +} + +// handleGatewayEvents serves an SSE stream of gateway state change events. +// +// GET /api/gateway/events +func (h *Handler) handleGatewayEvents(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "SSE not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + // Subscribe to gateway events + ch := gateway.events.Subscribe() + defer gateway.events.Unsubscribe(ch) + + // Send initial status so the client doesn't start blank + initial := h.currentGatewayStatus() + fmt.Fprintf(w, "data: %s\n\n", initial) + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + case data, ok := <-ch: + if !ok { + return + } + fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + } +} + +// currentGatewayStatus returns the current gateway status as a JSON string. +func (h *Handler) currentGatewayStatus() string { + gateway.mu.Lock() + defer gateway.mu.Unlock() + + data := map[string]any{ + "gateway_status": "stopped", + } + if isGatewayProcessAliveLocked() { + data["gateway_status"] = "running" + data["pid"] = gateway.cmd.Process.Pid + } + + ready, reason, readyErr := h.gatewayStartReady() + if readyErr != nil { + data["gateway_start_allowed"] = false + data["gateway_start_reason"] = readyErr.Error() + } else { + data["gateway_start_allowed"] = ready + if !ready { + data["gateway_start_reason"] = reason + } + } + + encoded, _ := json.Marshal(data) + return string(encoded) +} + +// scanPipe reads lines from r and appends them to buf. Returns when r reaches EOF. +func scanPipe(r io.Reader, buf *LogBuffer) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + buf.Append(scanner.Text()) + } +} diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go new file mode 100644 index 000000000..a499c1ea2 --- /dev/null +++ b/web/backend/api/gateway_host.go @@ -0,0 +1,66 @@ +package api + +import ( + "net" + "net/http" + "strconv" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func (h *Handler) effectiveLauncherPublic() bool { + if h.serverPublicExplicit { + return h.serverPublic + } + + cfg, err := h.loadLauncherConfig() + if err == nil { + return cfg.Public + } + + return h.serverPublic +} + +func (h *Handler) gatewayHostOverride() string { + if h.effectiveLauncherPublic() { + return "0.0.0.0" + } + return "" +} + +func (h *Handler) effectiveGatewayBindHost(cfg *config.Config) string { + if override := h.gatewayHostOverride(); override != "" { + return override + } + if cfg == nil { + return "" + } + return strings.TrimSpace(cfg.Gateway.Host) +} + +func gatewayProbeHost(bindHost string) string { + if bindHost == "" || bindHost == "0.0.0.0" { + return "127.0.0.1" + } + return bindHost +} + +func requestHostName(r *http.Request) string { + reqHost, _, err := net.SplitHostPort(r.Host) + if err == nil { + return reqHost + } + if strings.TrimSpace(r.Host) != "" { + return r.Host + } + return "127.0.0.1" +} + +func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string { + host := h.effectiveGatewayBindHost(cfg) + if host == "" || host == "0.0.0.0" { + host = requestHostName(r) + } + return "ws://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws" +} diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go new file mode 100644 index 000000000..afd600359 --- /dev/null +++ b/web/backend/api/gateway_host_test.go @@ -0,0 +1,59 @@ +package api + +import ( + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 18800, + Public: false, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, true, true, nil) + + if got := h.gatewayHostOverride(); got != "0.0.0.0" { + t.Fatalf("gatewayHostOverride() = %q, want %q", got, "0.0.0.0") + } +} + +func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 18800, + Public: true, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + h := NewHandler(configPath) + h.SetServerOptions(18800, false, false, nil) + + cfg := config.DefaultConfig() + cfg.Gateway.Host = "127.0.0.1" + cfg.Gateway.Port = 18790 + + req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil) + req.Host = "192.168.1.9:18800" + + if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18790/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18790/pico/ws") + } +} + +func TestGatewayProbeHostUsesLoopbackForWildcardBind(t *testing.T) { + if got := gatewayProbeHost("0.0.0.0"); got != "127.0.0.1" { + t.Fatalf("gatewayProbeHost() = %q, want %q", got, "127.0.0.1") + } +} diff --git a/web/backend/api/gateway_test.go b/web/backend/api/gateway_test.go new file mode 100644 index 000000000..d4265776a --- /dev/null +++ b/web/backend/api/gateway_test.go @@ -0,0 +1,410 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +func TestGatewayStartReady_NoDefaultModel(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false") + } + if reason != "no default model configured" { + t.Fatalf("gatewayStartReady() reason = %q, want %q", reason, "no default model configured") + } +} + +func TestGatewayStartReady_InvalidDefaultModel(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Model = "missing-model" + err := config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false") + } + if reason == "" { + t.Fatalf("gatewayStartReady() reason is empty") + } +} + +func TestGatewayStartReady_ValidDefaultModel(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].APIKey = "test-key" + err := config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true (reason=%q)", reason) + } +} + +func TestGatewayStartReady_DefaultModelWithoutCredential(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + cfg := config.DefaultConfig() + cfg.Agents.Defaults.ModelName = cfg.ModelList[0].ModelName + cfg.ModelList[0].APIKey = "" + cfg.ModelList[0].AuthMethod = "" + err := config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false") + } + if !strings.Contains(reason, "no credentials configured") { + t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured") + } +} + +func TestGatewayStartReady_LocalModelWithoutAPIKey(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + return false + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://localhost:8000/v1", + }} + cfg.Agents.Defaults.ModelName = "local-vllm" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false without a running local service") + } + if !strings.Contains(reason, "not reachable") { + t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "not reachable") + } +} + +func TestGatewayStartReady_LocalModelWithRunningService(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "local-vllm", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + }} + cfg.Agents.Defaults.ModelName = "local-vllm" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true with a running local service (reason=%q)", reason) + } +} + +func TestGatewayStartReady_RemoteVLLMWithAPIKeyDoesNotProbe(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + t.Fatalf("unexpected OpenAI-compatible probe for %q (%q)", apiBase, modelID) + return false + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "remote-vllm", + Model: "vllm/custom-model", + APIBase: "https://models.example.com/v1", + APIKey: "remote-key", + }} + cfg.Agents.Defaults.ModelName = "remote-vllm" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true for remote vllm with api key (reason=%q)", reason) + } +} + +func TestGatewayStartReady_LocalOllamaUsesDefaultProbeBase(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetModelProbeHooks(t) + + probeOllamaModelFunc = func(apiBase, modelID string) bool { + return apiBase == "http://localhost:11434/v1" && modelID == "llama3" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "local-ollama", + Model: "ollama/llama3", + }} + cfg.Agents.Defaults.ModelName = "local-ollama" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true with default Ollama probe base (reason=%q)", reason) + } +} + +func TestGatewayStartReady_OAuthModelRequiresStoredCredential(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "openai-oauth", + Model: "openai/gpt-5.4", + AuthMethod: "oauth", + }} + cfg.Agents.Defaults.ModelName = "openai-oauth" + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + ready, reason, err := h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if ready { + t.Fatalf("gatewayStartReady() ready = true, want false without stored credential") + } + if !strings.Contains(reason, "no credentials configured") { + t.Fatalf("gatewayStartReady() reason = %q, want contains %q", reason, "no credentials configured") + } + + err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ + AccessToken: "openai-token", + Provider: oauthProviderOpenAI, + AuthMethod: "oauth", + }) + if err != nil { + t.Fatalf("SetCredential() error = %v", err) + } + + ready, reason, err = h.gatewayStartReady() + if err != nil { + t.Fatalf("gatewayStartReady() error = %v", err) + } + if !ready { + t.Fatalf("gatewayStartReady() ready = false, want true with stored credential (reason=%q)", reason) + } +} + +func TestGatewayStatusIncludesStartConditionWhenNotReady(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/gateway/status", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + allowed, ok := body["gateway_start_allowed"].(bool) + if !ok { + t.Fatalf("gateway_start_allowed missing or not bool: %#v", body["gateway_start_allowed"]) + } + if allowed { + t.Fatalf("gateway_start_allowed = true, want false") + } + if _, ok := body["gateway_start_reason"].(string); !ok { + t.Fatalf("gateway_start_reason missing or not string: %#v", body["gateway_start_reason"]) + } +} + +func TestGatewayClearLogsResetsBufferedHistory(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + gateway.logs.Clear() + gateway.logs.Append("first line") + gateway.logs.Append("second line") + previousRunID := gateway.logs.RunID() + + clearRec := httptest.NewRecorder() + clearReq := httptest.NewRequest(http.MethodPost, "/api/gateway/logs/clear", nil) + mux.ServeHTTP(clearRec, clearReq) + + if clearRec.Code != http.StatusOK { + t.Fatalf("clear status = %d, want %d", clearRec.Code, http.StatusOK) + } + + var clearBody map[string]any + if err := json.Unmarshal(clearRec.Body.Bytes(), &clearBody); err != nil { + t.Fatalf("unmarshal clear response: %v", err) + } + + if got := clearBody["status"]; got != "cleared" { + t.Fatalf("clear status body = %#v, want %q", got, "cleared") + } + + clearRunID, ok := clearBody["log_run_id"].(float64) + if !ok { + t.Fatalf("log_run_id missing or not number: %#v", clearBody["log_run_id"]) + } + if int(clearRunID) <= previousRunID { + t.Fatalf("log_run_id = %d, want > %d", int(clearRunID), previousRunID) + } + + statusRec := httptest.NewRecorder() + statusReq := httptest.NewRequest( + http.MethodGet, + "/api/gateway/status?log_offset=0&log_run_id="+strconv.Itoa(previousRunID), + nil, + ) + mux.ServeHTTP(statusRec, statusReq) + + if statusRec.Code != http.StatusOK { + t.Fatalf("status code = %d, want %d", statusRec.Code, http.StatusOK) + } + + var statusBody map[string]any + if err := json.Unmarshal(statusRec.Body.Bytes(), &statusBody); err != nil { + t.Fatalf("unmarshal status response: %v", err) + } + + logs, ok := statusBody["logs"].([]any) + if !ok { + t.Fatalf("logs missing or not array: %#v", statusBody["logs"]) + } + if len(logs) != 0 { + t.Fatalf("logs len = %d, want 0", len(logs)) + } + if got := statusBody["log_total"]; got != float64(0) { + t.Fatalf("log_total = %#v, want 0", got) + } +} + +func TestFindPicoclawBinary_EnvOverride(t *testing.T) { + // Create a temporary file to act as the mock binary + tmpDir := t.TempDir() + mockBinary := filepath.Join(tmpDir, "picoclaw-mock") + if err := os.WriteFile(mockBinary, []byte("mock"), 0o755); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + t.Setenv("PICOCLAW_BINARY", mockBinary) + + got := utils.FindPicoclawBinary() + if got != mockBinary { + t.Errorf("FindPicoclawBinary() = %q, want %q", got, mockBinary) + } +} + +func TestFindPicoclawBinary_EnvOverride_InvalidPath(t *testing.T) { + // When PICOCLAW_BINARY points to a non-existent path, fall through to next strategy + t.Setenv("PICOCLAW_BINARY", "/nonexistent/picoclaw-binary") + + got := utils.FindPicoclawBinary() + // Should not return the invalid path; falls back to "picoclaw" or another found path + if got == "/nonexistent/picoclaw-binary" { + t.Errorf("FindPicoclawBinary() returned invalid env path %q, expected fallback", got) + } +} diff --git a/web/backend/api/launcher_config.go b/web/backend/api/launcher_config.go new file mode 100644 index 000000000..e149d5671 --- /dev/null +++ b/web/backend/api/launcher_config.go @@ -0,0 +1,85 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +type launcherConfigPayload struct { + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs"` +} + +func (h *Handler) registerLauncherConfigRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/system/launcher-config", h.handleGetLauncherConfig) + mux.HandleFunc("PUT /api/system/launcher-config", h.handleUpdateLauncherConfig) +} + +func (h *Handler) launcherConfigPath() string { + return launcherconfig.PathForAppConfig(h.configPath) +} + +func (h *Handler) launcherFallbackConfig() launcherconfig.Config { + port := h.serverPort + if port <= 0 { + port = launcherconfig.DefaultPort + } + return launcherconfig.Config{ + Port: port, + Public: h.serverPublic, + AllowedCIDRs: append([]string(nil), h.serverCIDRs...), + } +} + +func (h *Handler) loadLauncherConfig() (launcherconfig.Config, error) { + return launcherconfig.Load(h.launcherConfigPath(), h.launcherFallbackConfig()) +} + +func (h *Handler) handleGetLauncherConfig(w http.ResponseWriter, r *http.Request) { + cfg, err := h.loadLauncherConfig() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load launcher config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(launcherConfigPayload{ + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + }) +} + +func (h *Handler) handleUpdateLauncherConfig(w http.ResponseWriter, r *http.Request) { + var payload launcherConfigPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + cfg := launcherconfig.Config{ + Port: payload.Port, + Public: payload.Public, + AllowedCIDRs: append([]string(nil), payload.AllowedCIDRs...), + } + if err := launcherconfig.Validate(cfg); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := launcherconfig.Save(h.launcherConfigPath(), cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save launcher config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(launcherConfigPayload{ + Port: cfg.Port, + Public: cfg.Public, + AllowedCIDRs: append([]string(nil), cfg.AllowedCIDRs...), + }) +} diff --git a/web/backend/api/launcher_config_test.go b/web/backend/api/launcher_config_test.go new file mode 100644 index 000000000..0d6af823c --- /dev/null +++ b/web/backend/api/launcher_config_test.go @@ -0,0 +1,115 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +func TestGetLauncherConfigUsesRuntimeFallback(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + h.SetServerOptions(19999, true, false, []string{"192.168.1.0/24"}) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/system/launcher-config", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var got launcherConfigPayload + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if got.Port != 19999 || !got.Public { + t.Fatalf("response = %+v, want port=19999 public=true", got) + } + if len(got.AllowedCIDRs) != 1 || got.AllowedCIDRs[0] != "192.168.1.0/24" { + t.Fatalf("response allowed_cidrs = %v, want [192.168.1.0/24]", got.AllowedCIDRs) + } +} + +func TestPutLauncherConfigPersists(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/system/launcher-config", + strings.NewReader(`{"port":18080,"public":true,"allowed_cidrs":["192.168.1.0/24"]}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + path := launcherconfig.PathForAppConfig(configPath) + cfg, err := launcherconfig.Load(path, launcherconfig.Default()) + if err != nil { + t.Fatalf("launcherconfig.Load() error = %v", err) + } + if cfg.Port != 18080 || !cfg.Public { + t.Fatalf("saved config = %+v, want port=18080 public=true", cfg) + } + if len(cfg.AllowedCIDRs) != 1 || cfg.AllowedCIDRs[0] != "192.168.1.0/24" { + t.Fatalf("saved config allowed_cidrs = %v, want [192.168.1.0/24]", cfg.AllowedCIDRs) + } +} + +func TestPutLauncherConfigRejectsInvalidPort(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/system/launcher-config", + strings.NewReader(`{"port":70000,"public":false}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestPutLauncherConfigRejectsInvalidCIDR(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/system/launcher-config", + strings.NewReader(`{"port":18080,"public":false,"allowed_cidrs":["bad-cidr"]}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} diff --git a/web/backend/api/log.go b/web/backend/api/log.go new file mode 100644 index 000000000..f83f6f34c --- /dev/null +++ b/web/backend/api/log.go @@ -0,0 +1,97 @@ +package api + +import "sync" + +// LogBuffer is a thread-safe ring buffer that stores the most recent N log lines. +// It supports incremental reads via LinesSince and tracks a runID that increments +// whenever the buffer is reset or cleared so clients can detect log history resets. +type LogBuffer struct { + mu sync.RWMutex + lines []string + cap int + total int // total lines ever appended in current run + runID int +} + +// NewLogBuffer creates a LogBuffer with the given capacity. +func NewLogBuffer(capacity int) *LogBuffer { + return &LogBuffer{ + lines: make([]string, 0, capacity), + cap: capacity, + } +} + +// Append adds a line to the buffer. If the buffer is full, the oldest line is evicted. +func (b *LogBuffer) Append(line string) { + b.mu.Lock() + defer b.mu.Unlock() + + if len(b.lines) < b.cap { + b.lines = append(b.lines, line) + } else { + b.lines[b.total%b.cap] = line + } + + b.total++ +} + +// Reset clears the buffer and increments the runID. Call this when starting a new gateway process. +func (b *LogBuffer) Reset() { + b.mu.Lock() + defer b.mu.Unlock() + + b.lines = b.lines[:0] + b.total = 0 + b.runID++ +} + +// Clear removes all buffered lines and increments the runID so clients treat +// subsequent reads as a new log stream. +func (b *LogBuffer) Clear() { + b.Reset() +} + +// LinesSince returns lines appended after the given offset, the current total count, and the runID. +// If offset >= total, no lines are returned. If offset is too old (evicted), all buffered lines are returned. +func (b *LogBuffer) LinesSince(offset int) (lines []string, total int, runID int) { + b.mu.RLock() + defer b.mu.RUnlock() + + total = b.total + runID = b.runID + + if offset >= b.total { + return nil, total, runID + } + + buffered := len(b.lines) + + // How many new lines since offset + newCount := b.total - offset + if newCount > buffered { + newCount = buffered + } + + result := make([]string, newCount) + + if b.total <= b.cap { + // Buffer hasn't wrapped yet — simple slice + copy(result, b.lines[buffered-newCount:]) + } else { + // Buffer has wrapped — read from ring + start := (b.total - newCount) % b.cap + for i := range newCount { + result[i] = b.lines[(start+i)%b.cap] + } + } + + return result, total, runID +} + +// RunID returns the current run identifier. +func (b *LogBuffer) RunID() int { + b.mu.RLock() + defer b.mu.RUnlock() + + return b.runID +} diff --git a/web/backend/api/model_status.go b/web/backend/api/model_status.go new file mode 100644 index 000000000..22bf5c15b --- /dev/null +++ b/web/backend/api/model_status.go @@ -0,0 +1,324 @@ +package api + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +const modelProbeTimeout = 800 * time.Millisecond + +var ( + probeTCPServiceFunc = probeTCPService + probeOllamaModelFunc = probeOllamaModel + probeOpenAICompatibleModelFunc = probeOpenAICompatibleModel +) + +func hasModelConfiguration(m config.ModelConfig) bool { + authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) + apiKey := strings.TrimSpace(m.APIKey) + + if authMethod == "oauth" || authMethod == "token" { + if provider, ok := oauthProviderForModel(m.Model); ok { + cred, err := oauthGetCredential(provider) + if err != nil || cred == nil { + return false + } + return strings.TrimSpace(cred.AccessToken) != "" || strings.TrimSpace(cred.RefreshToken) != "" + } + return true + } + + if requiresRuntimeProbe(m) { + return true + } + + return apiKey != "" +} + +// isModelConfigured reports whether a model is currently available to use. +// Local models must be reachable; remote/API-key models only need saved config. +func isModelConfigured(m config.ModelConfig) bool { + if !hasModelConfiguration(m) { + return false + } + if requiresRuntimeProbe(m) { + return probeLocalModelAvailability(m) + } + return true +} + +func requiresRuntimeProbe(m config.ModelConfig) bool { + authMethod := strings.ToLower(strings.TrimSpace(m.AuthMethod)) + if authMethod == "local" { + return true + } + + switch modelProtocol(m.Model) { + case "claude-cli", "claudecli", "codex-cli", "codexcli", "github-copilot", "copilot": + return true + case "ollama", "vllm": + apiBase := strings.TrimSpace(m.APIBase) + return apiBase == "" || hasLocalAPIBase(apiBase) + } + + if hasLocalAPIBase(m.APIBase) { + return true + } + + return false +} + +func probeLocalModelAvailability(m config.ModelConfig) bool { + apiBase := modelProbeAPIBase(m) + protocol, modelID := splitModel(m.Model) + switch protocol { + case "ollama": + return probeOllamaModelFunc(apiBase, modelID) + case "vllm": + return probeOpenAICompatibleModelFunc(apiBase, modelID) + case "github-copilot", "copilot": + return probeTCPServiceFunc(apiBase) + case "claude-cli", "claudecli", "codex-cli", "codexcli": + return true + default: + if hasLocalAPIBase(apiBase) { + return probeOpenAICompatibleModelFunc(apiBase, modelID) + } + return false + } +} + +func modelProbeAPIBase(m config.ModelConfig) string { + if apiBase := strings.TrimSpace(m.APIBase); apiBase != "" { + return normalizeModelProbeAPIBase(apiBase) + } + + switch modelProtocol(m.Model) { + case "ollama": + return "http://localhost:11434/v1" + case "vllm": + return "http://localhost:8000/v1" + case "github-copilot", "copilot": + return "localhost:4321" + default: + return "" + } +} + +func normalizeModelProbeAPIBase(raw string) string { + u, err := parseAPIBase(raw) + if err != nil { + return strings.TrimSpace(raw) + } + + switch strings.ToLower(u.Hostname()) { + case "0.0.0.0": + u.Host = net.JoinHostPort("127.0.0.1", u.Port()) + case "::": + u.Host = net.JoinHostPort("::1", u.Port()) + default: + return strings.TrimSpace(raw) + } + + if u.Port() == "" { + u.Host = u.Hostname() + } + + return u.String() +} + +func oauthProviderForModel(model string) (string, bool) { + switch modelProtocol(model) { + case "openai": + return oauthProviderOpenAI, true + case "anthropic": + return oauthProviderAnthropic, true + case "antigravity", "google-antigravity": + return oauthProviderGoogleAntigravity, true + default: + return "", false + } +} + +func modelProtocol(model string) string { + protocol, _ := splitModel(model) + return protocol +} + +func splitModel(model string) (protocol, modelID string) { + model = strings.ToLower(strings.TrimSpace(model)) + protocol, _, found := strings.Cut(model, "/") + if !found { + return "openai", model + } + return protocol, strings.TrimSpace(model[strings.Index(model, "/")+1:]) +} + +func hasLocalAPIBase(raw string) bool { + raw = strings.TrimSpace(raw) + if raw == "" { + return false + } + + u, err := url.Parse(raw) + if err != nil || u.Hostname() == "" { + u, err = url.Parse("//" + raw) + if err != nil { + return false + } + } + + switch strings.ToLower(u.Hostname()) { + case "localhost", "127.0.0.1", "::1", "0.0.0.0": + return true + default: + return false + } +} + +func probeTCPService(raw string) bool { + hostPort, err := hostPortFromAPIBase(raw) + if err != nil { + return false + } + + conn, err := net.DialTimeout("tcp", hostPort, modelProbeTimeout) + if err != nil { + return false + } + _ = conn.Close() + return true +} + +func probeOllamaModel(apiBase, modelID string) bool { + root, err := apiRootFromAPIBase(apiBase) + if err != nil { + return false + } + + var resp struct { + Models []struct { + Name string `json:"name"` + Model string `json:"model"` + } `json:"models"` + } + if err := getJSON(root+"/api/tags", &resp); err != nil { + return false + } + + for _, model := range resp.Models { + if ollamaModelMatches(model.Name, modelID) || ollamaModelMatches(model.Model, modelID) { + return true + } + } + return false +} + +func probeOpenAICompatibleModel(apiBase, modelID string) bool { + if strings.TrimSpace(apiBase) == "" { + return false + } + + var resp struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := getJSON(strings.TrimRight(strings.TrimSpace(apiBase), "/")+"/models", &resp); err != nil { + return false + } + + for _, model := range resp.Data { + if strings.EqualFold(strings.TrimSpace(model.ID), modelID) { + return true + } + } + return false +} + +func getJSON(rawURL string, out any) error { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return err + } + + client := &http.Client{Timeout: modelProbeTimeout} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + return json.NewDecoder(resp.Body).Decode(out) +} + +func apiRootFromAPIBase(raw string) (string, error) { + u, err := parseAPIBase(raw) + if err != nil { + return "", err + } + return (&url.URL{Scheme: u.Scheme, Host: u.Host}).String(), nil +} + +func hostPortFromAPIBase(raw string) (string, error) { + u, err := parseAPIBase(raw) + if err != nil { + return "", err + } + + if port := u.Port(); port != "" { + return u.Host, nil + } + switch strings.ToLower(u.Scheme) { + case "https": + return net.JoinHostPort(u.Hostname(), "443"), nil + default: + return net.JoinHostPort(u.Hostname(), "80"), nil + } +} + +func parseAPIBase(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty api base") + } + + u, err := url.Parse(raw) + if err == nil && u.Hostname() != "" { + return u, nil + } + + u, err = url.Parse("//" + raw) + if err != nil || u.Hostname() == "" { + return nil, fmt.Errorf("invalid api base %q", raw) + } + if u.Scheme == "" { + u.Scheme = "http" + } + return u, nil +} + +func ollamaModelMatches(candidate, want string) bool { + candidate = strings.TrimSpace(candidate) + want = strings.TrimSpace(want) + if candidate == "" || want == "" { + return false + } + if strings.EqualFold(candidate, want) { + return true + } + + base, _, _ := strings.Cut(candidate, ":") + return strings.EqualFold(base, want) +} diff --git a/web/backend/api/models.go b/web/backend/api/models.go new file mode 100644 index 000000000..7f3d29c77 --- /dev/null +++ b/web/backend/api/models.go @@ -0,0 +1,310 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "sync" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// registerModelRoutes binds model list management endpoints to the ServeMux. +func (h *Handler) registerModelRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/models", h.handleListModels) + mux.HandleFunc("POST /api/models", h.handleAddModel) + mux.HandleFunc("POST /api/models/default", h.handleSetDefaultModel) + mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel) + mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel) +} + +// modelResponse is the JSON structure returned for each model in the list. +// All ModelConfig fields are included so the frontend can display and edit them. +type modelResponse struct { + Index int `json:"index"` + ModelName string `json:"model_name"` + Model string `json:"model"` + APIBase string `json:"api_base,omitempty"` + APIKey string `json:"api_key"` + Proxy string `json:"proxy,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` + // Advanced fields + ConnectMode string `json:"connect_mode,omitempty"` + Workspace string `json:"workspace,omitempty"` + RPM int `json:"rpm,omitempty"` + MaxTokensField string `json:"max_tokens_field,omitempty"` + RequestTimeout int `json:"request_timeout,omitempty"` + ThinkingLevel string `json:"thinking_level,omitempty"` + // Meta + Configured bool `json:"configured"` + IsDefault bool `json:"is_default"` +} + +// handleListModels returns all model_list entries with masked API keys. +// +// GET /api/models +func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + defaultModel := cfg.Agents.Defaults.GetModelName() + configured := make([]bool, len(cfg.ModelList)) + + var wg sync.WaitGroup + wg.Add(len(cfg.ModelList)) + for i, m := range cfg.ModelList { + go func(i int, m config.ModelConfig) { + defer wg.Done() + configured[i] = isModelConfigured(m) + }(i, m) + } + wg.Wait() + + models := make([]modelResponse, 0, len(cfg.ModelList)) + for i, m := range cfg.ModelList { + models = append(models, modelResponse{ + Index: i, + ModelName: m.ModelName, + Model: m.Model, + APIBase: m.APIBase, + APIKey: maskAPIKey(m.APIKey), + Proxy: m.Proxy, + AuthMethod: m.AuthMethod, + ConnectMode: m.ConnectMode, + Workspace: m.Workspace, + RPM: m.RPM, + MaxTokensField: m.MaxTokensField, + RequestTimeout: m.RequestTimeout, + ThinkingLevel: m.ThinkingLevel, + Configured: configured[i], + IsDefault: m.ModelName == defaultModel, + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "models": models, + "total": len(models), + "default_model": defaultModel, + }) +} + +// handleAddModel appends a new model configuration entry. +// +// POST /api/models +func (h *Handler) handleAddModel(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var mc config.ModelConfig + if err = json.Unmarshal(body, &mc); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if err = mc.Validate(); err != nil { + http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + cfg.ModelList = append(cfg.ModelList, mc) + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "index": len(cfg.ModelList) - 1, + }) +} + +// handleUpdateModel replaces a model configuration entry at the given index. +// If the request body omits api_key (or sends an empty string), the existing +// stored key is preserved so callers can update only api_base / proxy without +// exposing or clearing the secret. +// +// PUT /api/models/{index} +func (h *Handler) handleUpdateModel(w http.ResponseWriter, r *http.Request) { + idx, err := strconv.Atoi(r.PathValue("index")) + if err != nil { + http.Error(w, "Invalid index", http.StatusBadRequest) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var mc config.ModelConfig + if err = json.Unmarshal(body, &mc); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if err = mc.Validate(); err != nil { + http.Error(w, fmt.Sprintf("Validation error: %v", err), http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + if idx < 0 || idx >= len(cfg.ModelList) { + http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) + return + } + + // Preserve the existing API key when the caller omits it (empty string). + // This lets the UI update api_base / proxy without clearing the stored secret. + if mc.APIKey == "" { + mc.APIKey = cfg.ModelList[idx].APIKey + } + + cfg.ModelList[idx] = mc + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleDeleteModel removes a model configuration entry at the given index. +// +// DELETE /api/models/{index} +func (h *Handler) handleDeleteModel(w http.ResponseWriter, r *http.Request) { + idx, err := strconv.Atoi(r.PathValue("index")) + if err != nil { + http.Error(w, "Invalid index", http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + if idx < 0 || idx >= len(cfg.ModelList) { + http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) + return + } + + deletedModelName := cfg.ModelList[idx].ModelName + + cfg.ModelList = append(cfg.ModelList[:idx], cfg.ModelList[idx+1:]...) + + // If the deleted model was the default, clear it. + if cfg.Agents.Defaults.ModelName == deletedModelName { + cfg.Agents.Defaults.ModelName = "" + } + if cfg.Agents.Defaults.Model == deletedModelName { + cfg.Agents.Defaults.Model = "" + } + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +// handleSetDefaultModel sets the default model for all agents. +// +// POST /api/models/default +func (h *Handler) handleSetDefaultModel(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var req struct { + ModelName string `json:"model_name"` + } + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if req.ModelName == "" { + http.Error(w, "model_name is required", http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + // Verify the model_name exists in model_list + found := false + for _, m := range cfg.ModelList { + if m.ModelName == req.ModelName { + found = true + break + } + } + if !found { + http.Error(w, fmt.Sprintf("Model %q not found in model_list", req.ModelName), http.StatusNotFound) + return + } + + cfg.Agents.Defaults.ModelName = req.ModelName + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "default_model": req.ModelName, + }) +} + +// maskAPIKey returns a masked version of an API key for safe display. +// Keys longer than 8 chars show prefix + last 4 chars: "sk-****abcd" +// Shorter keys are fully masked as "****". +// Empty keys return empty string. +func maskAPIKey(key string) string { + if key == "" { + return "" + } + if len(key) <= 8 { + return "****" + } + // Show first 3 chars and last 4 chars + return key[:3] + "****" + key[len(key)-4:] +} diff --git a/web/backend/api/models_test.go b/web/backend/api/models_test.go new file mode 100644 index 000000000..2377b5b66 --- /dev/null +++ b/web/backend/api/models_test.go @@ -0,0 +1,313 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +func resetModelProbeHooks(t *testing.T) { + t.Helper() + + origTCPProbe := probeTCPServiceFunc + origOllamaProbe := probeOllamaModelFunc + origOpenAIProbe := probeOpenAICompatibleModelFunc + t.Cleanup(func() { + probeTCPServiceFunc = origTCPProbe + probeOllamaModelFunc = origOllamaProbe + probeOpenAICompatibleModelFunc = origOpenAIProbe + }) +} + +func TestHandleListModels_ConfiguredStatusUsesRuntimeProbesForLocalModels(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + var mu sync.Mutex + var openAIProbes []string + var ollamaProbes []string + var tcpProbes []string + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + mu.Lock() + openAIProbes = append(openAIProbes, apiBase+"|"+modelID) + mu.Unlock() + return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" + } + probeOllamaModelFunc = func(apiBase, modelID string) bool { + mu.Lock() + ollamaProbes = append(ollamaProbes, apiBase+"|"+modelID) + mu.Unlock() + return apiBase == "http://localhost:11434/v1" && modelID == "llama3" + } + probeTCPServiceFunc = func(apiBase string) bool { + mu.Lock() + tcpProbes = append(tcpProbes, apiBase) + mu.Unlock() + return apiBase == "http://127.0.0.1:4321" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{ + { + ModelName: "openai-oauth", + Model: "openai/gpt-5.4", + AuthMethod: "oauth", + }, + { + ModelName: "vllm-local", + Model: "vllm/custom-model", + APIBase: "http://127.0.0.1:8000/v1", + }, + { + ModelName: "ollama-default", + Model: "ollama/llama3", + }, + { + ModelName: "vllm-remote", + Model: "vllm/custom-model", + APIBase: "https://models.example.com/v1", + APIKey: "remote-key", + }, + { + ModelName: "copilot-gpt-5.4", + Model: "github-copilot/gpt-5.4", + APIBase: "http://127.0.0.1:4321", + AuthMethod: "oauth", + }, + } + cfg.Agents.Defaults.ModelName = "openai-oauth" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + + got := make(map[string]bool, len(resp.Models)) + for _, model := range resp.Models { + got[model.ModelName] = model.Configured + } + + if got["openai-oauth"] { + t.Fatalf("openai oauth model configured = true, want false without stored credential") + } + if !got["vllm-local"] { + t.Fatalf("vllm local model configured = false, want true when local probe succeeds") + } + if !got["ollama-default"] { + t.Fatalf("ollama default model configured = false, want true when default local probe succeeds") + } + if !got["vllm-remote"] { + t.Fatalf("remote vllm model configured = false, want true with api_key") + } + if !got["copilot-gpt-5.4"] { + t.Fatalf("copilot model configured = false, want true when local bridge probe succeeds") + } + if len(openAIProbes) != 1 || openAIProbes[0] != "http://127.0.0.1:8000/v1|custom-model" { + t.Fatalf("openAI probes = %#v, want only local vllm probe", openAIProbes) + } + if len(ollamaProbes) != 1 || ollamaProbes[0] != "http://localhost:11434/v1|llama3" { + t.Fatalf("ollama probes = %#v, want default local probe", ollamaProbes) + } + if len(tcpProbes) != 1 || tcpProbes[0] != "http://127.0.0.1:4321" { + t.Fatalf("tcp probes = %#v, want only local copilot probe", tcpProbes) + } +} + +func TestHandleListModels_ConfiguredStatusForOAuthModelWithCredential(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "claude-oauth", + Model: "anthropic/claude-sonnet-4.6", + AuthMethod: "oauth", + }} + cfg.Agents.Defaults.ModelName = "claude-oauth" + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := auth.SetCredential(oauthProviderAnthropic, &auth.AuthCredential{ + AccessToken: "anthropic-token", + Provider: oauthProviderAnthropic, + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Configured { + t.Fatalf("oauth model configured = false, want true with stored credential") + } +} + +func TestHandleListModels_ProbesLocalModelsConcurrently(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + started := make(chan string, 2) + release := make(chan struct{}) + + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + started <- apiBase + "|" + modelID + <-release + return true + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{ + { + ModelName: "local-vllm-a", + Model: "vllm/custom-a", + APIBase: "http://127.0.0.1:8000/v1", + }, + { + ModelName: "local-vllm-b", + Model: "vllm/custom-b", + APIBase: "http://127.0.0.1:8001/v1", + }, + } + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + recCh := make(chan *httptest.ResponseRecorder, 1) + go func() { + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + recCh <- rec + }() + + for i := 0; i < 2; i++ { + select { + case <-started: + case <-time.After(200 * time.Millisecond): + t.Fatal("expected both local probes to start before the first one completed") + } + } + close(release) + + rec := <-recCh + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandleListModels_NormalizesWildcardLocalAPIBaseForProbe(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + resetModelProbeHooks(t) + + var gotProbe string + probeOpenAICompatibleModelFunc = func(apiBase, modelID string) bool { + gotProbe = apiBase + "|" + modelID + return apiBase == "http://127.0.0.1:8000/v1" && modelID == "custom-model" + } + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.ModelList = []config.ModelConfig{{ + ModelName: "vllm-local", + Model: "vllm/custom-model", + APIBase: "http://0.0.0.0:8000/v1", + }} + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/models", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + Models []modelResponse `json:"models"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Models) != 1 { + t.Fatalf("len(models) = %d, want 1", len(resp.Models)) + } + if !resp.Models[0].Configured { + t.Fatal("wildcard-bound local model configured = false, want true after probe host normalization") + } + if gotProbe != "http://127.0.0.1:8000/v1|custom-model" { + t.Fatalf("probe api base = %q, want %q", gotProbe, "http://127.0.0.1:8000/v1|custom-model") + } +} diff --git a/web/backend/api/oauth.go b/web/backend/api/oauth.go new file mode 100644 index 000000000..919b47fbc --- /dev/null +++ b/web/backend/api/oauth.go @@ -0,0 +1,844 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "html" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +const ( + oauthProviderOpenAI = "openai" + oauthProviderAnthropic = "anthropic" + oauthProviderGoogleAntigravity = "google-antigravity" + + oauthMethodBrowser = "browser" + oauthMethodDeviceCode = "device_code" + oauthMethodToken = "token" + + oauthFlowPending = "pending" + oauthFlowSuccess = "success" + oauthFlowError = "error" + oauthFlowExpired = "expired" +) + +const ( + oauthBrowserFlowTTL = 10 * time.Minute + oauthDeviceCodeFlowTTL = 15 * time.Minute + oauthTerminalFlowGC = 30 * time.Minute +) + +var oauthProviderOrder = []string{ + oauthProviderOpenAI, + oauthProviderAnthropic, + oauthProviderGoogleAntigravity, +} + +var oauthProviderMethods = map[string][]string{ + oauthProviderOpenAI: {oauthMethodBrowser, oauthMethodDeviceCode, oauthMethodToken}, + oauthProviderAnthropic: {oauthMethodToken}, + oauthProviderGoogleAntigravity: {oauthMethodBrowser}, +} + +var oauthProviderLabels = map[string]string{ + oauthProviderOpenAI: "OpenAI", + oauthProviderAnthropic: "Anthropic", + oauthProviderGoogleAntigravity: "Google Antigravity", +} + +var ( + oauthNow = time.Now + oauthGeneratePKCE = auth.GeneratePKCE + oauthGenerateState = auth.GenerateState + oauthBuildAuthorizeURL = auth.BuildAuthorizeURL + oauthRequestDeviceCode = auth.RequestDeviceCode + oauthPollDeviceCodeOnce = auth.PollDeviceCodeOnce + oauthExchangeCodeForTokens = auth.ExchangeCodeForTokens + oauthGetCredential = auth.GetCredential + oauthSetCredential = auth.SetCredential + oauthDeleteCredential = auth.DeleteCredential + oauthLoadConfig = config.LoadConfig + oauthSaveConfig = config.SaveConfig + oauthFetchAntigravityProject = providers.FetchAntigravityProjectID + oauthFetchGoogleUserEmailFunc = fetchGoogleUserEmail +) + +type oauthFlow struct { + ID string + Provider string + Method string + Status string + CreatedAt time.Time + UpdatedAt time.Time + ExpiresAt time.Time + Error string + CodeVerifier string + OAuthState string + RedirectURI string + DeviceAuthID string + UserCode string + VerifyURL string + Interval int +} + +type oauthProviderStatus struct { + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + Methods []string `json:"methods"` + LoggedIn bool `json:"logged_in"` + Status string `json:"status"` + AuthMethod string `json:"auth_method,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + AccountID string `json:"account_id,omitempty"` + Email string `json:"email,omitempty"` + ProjectID string `json:"project_id,omitempty"` +} + +type oauthFlowResponse struct { + FlowID string `json:"flow_id"` + Provider string `json:"provider"` + Method string `json:"method"` + Status string `json:"status"` + ExpiresAt string `json:"expires_at,omitempty"` + Error string `json:"error,omitempty"` + UserCode string `json:"user_code,omitempty"` + VerifyURL string `json:"verify_url,omitempty"` + Interval int `json:"interval,omitempty"` +} + +// registerOAuthRoutes binds OAuth login/logout endpoints to the ServeMux. +func (h *Handler) registerOAuthRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/oauth/providers", h.handleListOAuthProviders) + mux.HandleFunc("POST /api/oauth/login", h.handleOAuthLogin) + mux.HandleFunc("GET /api/oauth/flows/{id}", h.handleGetOAuthFlow) + mux.HandleFunc("POST /api/oauth/flows/{id}/poll", h.handlePollOAuthFlow) + mux.HandleFunc("POST /api/oauth/logout", h.handleOAuthLogout) + mux.HandleFunc("GET /oauth/callback", h.handleOAuthCallback) +} + +func (h *Handler) handleListOAuthProviders(w http.ResponseWriter, r *http.Request) { + providersResp := make([]oauthProviderStatus, 0, len(oauthProviderOrder)) + + for _, provider := range oauthProviderOrder { + cred, err := oauthGetCredential(provider) + if err != nil { + http.Error(w, fmt.Sprintf("failed to load credentials: %v", err), http.StatusInternalServerError) + return + } + + item := oauthProviderStatus{ + Provider: provider, + DisplayName: oauthProviderLabels[provider], + Methods: oauthProviderMethods[provider], + Status: "not_logged_in", + } + if cred != nil { + item.LoggedIn = true + item.AuthMethod = cred.AuthMethod + item.AccountID = cred.AccountID + item.Email = cred.Email + item.ProjectID = cred.ProjectID + if !cred.ExpiresAt.IsZero() { + item.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339) + } + switch { + case cred.IsExpired(): + item.Status = "expired" + case cred.NeedsRefresh(): + item.Status = "needs_refresh" + default: + item.Status = "connected" + } + } + + providersResp = append(providersResp, item) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "providers": providersResp, + }) +} + +func (h *Handler) handleOAuthLogin(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var req struct { + Provider string `json:"provider"` + Method string `json:"method"` + Token string `json:"token"` + } + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + + provider, err := normalizeOAuthProvider(req.Provider) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + method := strings.ToLower(strings.TrimSpace(req.Method)) + if !isOAuthMethodSupported(provider, method) { + http.Error( + w, + fmt.Sprintf("unsupported login method %q for provider %q", method, provider), + http.StatusBadRequest, + ) + return + } + + switch method { + case oauthMethodToken: + token := strings.TrimSpace(req.Token) + if token == "" { + http.Error(w, "token is required", http.StatusBadRequest) + return + } + + cred := &auth.AuthCredential{ + AccessToken: token, + Provider: provider, + AuthMethod: oauthMethodToken, + } + if err := h.persistCredentialAndConfig(provider, oauthMethodToken, cred); err != nil { + http.Error(w, fmt.Sprintf("token login failed: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "provider": provider, + "method": method, + }) + return + + case oauthMethodDeviceCode: + cfg := auth.OpenAIOAuthConfig() + info, err := oauthRequestDeviceCode(cfg) + if err != nil { + http.Error(w, fmt.Sprintf("failed to request device code: %v", err), http.StatusInternalServerError) + return + } + + now := oauthNow() + flow := &oauthFlow{ + ID: newOAuthFlowID(), + Provider: provider, + Method: method, + Status: oauthFlowPending, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(oauthDeviceCodeFlowTTL), + DeviceAuthID: info.DeviceAuthID, + UserCode: info.UserCode, + VerifyURL: info.VerifyURL, + Interval: info.Interval, + } + h.storeOAuthFlow(flow) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "provider": provider, + "method": method, + "flow_id": flow.ID, + "user_code": flow.UserCode, + "verify_url": flow.VerifyURL, + "interval": flow.Interval, + "expires_at": flow.ExpiresAt.Format(time.RFC3339), + }) + return + + case oauthMethodBrowser: + cfg, err := oauthConfigForProvider(provider) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + pkce, err := oauthGeneratePKCE() + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate PKCE: %v", err), http.StatusInternalServerError) + return + } + state, err := oauthGenerateState() + if err != nil { + http.Error(w, fmt.Sprintf("failed to generate state: %v", err), http.StatusInternalServerError) + return + } + + redirectURI := buildOAuthRedirectURI(r) + authURL := oauthBuildAuthorizeURL(cfg, pkce, state, redirectURI) + + now := oauthNow() + flow := &oauthFlow{ + ID: newOAuthFlowID(), + Provider: provider, + Method: method, + Status: oauthFlowPending, + CreatedAt: now, + UpdatedAt: now, + ExpiresAt: now.Add(oauthBrowserFlowTTL), + CodeVerifier: pkce.CodeVerifier, + OAuthState: state, + RedirectURI: redirectURI, + } + h.storeOAuthFlow(flow) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "provider": provider, + "method": method, + "flow_id": flow.ID, + "auth_url": authURL, + "expires_at": flow.ExpiresAt.Format(time.RFC3339), + }) + return + default: + http.Error(w, "unsupported login method", http.StatusBadRequest) + } +} + +func (h *Handler) handleGetOAuthFlow(w http.ResponseWriter, r *http.Request) { + flowID := strings.TrimSpace(r.PathValue("id")) + if flowID == "" { + http.Error(w, "missing flow id", http.StatusBadRequest) + return + } + + flow, ok := h.getOAuthFlow(flowID) + if !ok { + http.Error(w, "flow not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(flow)) +} + +func (h *Handler) handlePollOAuthFlow(w http.ResponseWriter, r *http.Request) { + flowID := strings.TrimSpace(r.PathValue("id")) + if flowID == "" { + http.Error(w, "missing flow id", http.StatusBadRequest) + return + } + + flow, ok := h.getOAuthFlow(flowID) + if !ok { + http.Error(w, "flow not found", http.StatusNotFound) + return + } + + if flow.Method != oauthMethodDeviceCode { + http.Error(w, "flow does not support polling", http.StatusBadRequest) + return + } + if flow.Status != oauthFlowPending { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(flow)) + return + } + + cfg := auth.OpenAIOAuthConfig() + cred, err := oauthPollDeviceCodeOnce(cfg, flow.DeviceAuthID, flow.UserCode) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "pending") { + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) + return + } + h.setOAuthFlowError(flowID, fmt.Sprintf("device code poll failed: %v", err)) + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) + return + } + if cred == nil { + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) + return + } + + if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil { + h.setOAuthFlowError(flowID, fmt.Sprintf("failed to save credential: %v", err)) + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) + return + } + + h.setOAuthFlowSuccess(flowID) + updated, _ := h.getOAuthFlow(flowID) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(flowToResponse(updated)) +} + +func (h *Handler) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { + state := strings.TrimSpace(r.URL.Query().Get("state")) + if state == "" { + renderOAuthCallbackPage(w, "", oauthFlowError, "Missing state", "missing_state") + return + } + + flow, ok := h.getOAuthFlowByState(state) + if !ok { + renderOAuthCallbackPage(w, "", oauthFlowError, "OAuth flow not found", "flow_not_found") + return + } + + if flow.Status != oauthFlowPending { + renderOAuthCallbackPage(w, flow.ID, flow.Status, "Flow already completed", flow.Error) + return + } + + if errMsg := strings.TrimSpace(r.URL.Query().Get("error")); errMsg != "" { + if desc := strings.TrimSpace(r.URL.Query().Get("error_description")); desc != "" { + errMsg += ": " + desc + } + h.setOAuthFlowError(flow.ID, errMsg) + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Authorization failed", errMsg) + return + } + + code := strings.TrimSpace(r.URL.Query().Get("code")) + if code == "" { + h.setOAuthFlowError(flow.ID, "missing authorization code") + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Missing authorization code", "missing_code") + return + } + + cfg, err := oauthConfigForProvider(flow.Provider) + if err != nil { + h.setOAuthFlowError(flow.ID, err.Error()) + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Unsupported provider", err.Error()) + return + } + + cred, err := oauthExchangeCodeForTokens(cfg, code, flow.CodeVerifier, flow.RedirectURI) + if err != nil { + h.setOAuthFlowError(flow.ID, fmt.Sprintf("token exchange failed: %v", err)) + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Token exchange failed", err.Error()) + return + } + + if err := h.persistCredentialAndConfig(flow.Provider, oauthMethodTokenOrOAuth(flow.Method), cred); err != nil { + h.setOAuthFlowError(flow.ID, fmt.Sprintf("failed to save credential: %v", err)) + renderOAuthCallbackPage(w, flow.ID, oauthFlowError, "Failed to save credential", err.Error()) + return + } + + h.setOAuthFlowSuccess(flow.ID) + renderOAuthCallbackPage(w, flow.ID, oauthFlowSuccess, "Authentication successful", "") +} + +func (h *Handler) handleOAuthLogout(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "failed to read request body", http.StatusBadRequest) + return + } + defer r.Body.Close() + + var req struct { + Provider string `json:"provider"` + } + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("invalid JSON: %v", err), http.StatusBadRequest) + return + } + + provider, err := normalizeOAuthProvider(req.Provider) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := oauthDeleteCredential(provider); err != nil { + http.Error(w, fmt.Sprintf("failed to delete credential: %v", err), http.StatusInternalServerError) + return + } + if err := h.syncProviderAuthMethod(provider, ""); err != nil { + http.Error(w, fmt.Sprintf("failed to update config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "status": "ok", + "provider": provider, + }) +} + +func renderOAuthCallbackPage(w http.ResponseWriter, flowID, status, title, errMsg string) { + payload := map[string]string{ + "type": "picoclaw-oauth-result", + "flowId": flowID, + "status": status, + } + if errMsg != "" { + payload["error"] = errMsg + } + payloadJSON, _ := json.Marshal(payload) + + message := title + if errMsg != "" { + message = fmt.Sprintf("%s: %s", title, errMsg) + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if status == oauthFlowSuccess { + w.WriteHeader(http.StatusOK) + } else { + w.WriteHeader(http.StatusBadRequest) + } + + _, _ = fmt.Fprintf( + w, + "<!doctype html><html><head><meta charset=\"utf-8\"><title>PicoClaw OAuth

%s

%s

You can close this window.

", + string(payloadJSON), + html.EscapeString(title), + html.EscapeString(message), + ) +} + +func normalizeOAuthProvider(raw string) (string, error) { + provider := strings.ToLower(strings.TrimSpace(raw)) + switch provider { + case "antigravity": + return oauthProviderGoogleAntigravity, nil + case oauthProviderOpenAI, oauthProviderAnthropic, oauthProviderGoogleAntigravity: + return provider, nil + default: + return "", fmt.Errorf("unsupported provider %q", raw) + } +} + +func isOAuthMethodSupported(provider, method string) bool { + methods := oauthProviderMethods[provider] + for _, m := range methods { + if m == method { + return true + } + } + return false +} + +func oauthConfigForProvider(provider string) (auth.OAuthProviderConfig, error) { + switch provider { + case oauthProviderOpenAI: + return auth.OpenAIOAuthConfig(), nil + case oauthProviderGoogleAntigravity: + return auth.GoogleAntigravityOAuthConfig(), nil + default: + return auth.OAuthProviderConfig{}, fmt.Errorf("provider %q does not support browser oauth", provider) + } +} + +func oauthMethodTokenOrOAuth(method string) string { + if method == oauthMethodToken { + return oauthMethodToken + } + return "oauth" +} + +func buildOAuthRedirectURI(r *http.Request) string { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" { + scheme = strings.Split(forwarded, ",")[0] + } + return fmt.Sprintf("%s://%s/oauth/callback", scheme, r.Host) +} + +func flowToResponse(flow *oauthFlow) oauthFlowResponse { + resp := oauthFlowResponse{ + FlowID: flow.ID, + Provider: flow.Provider, + Method: flow.Method, + Status: flow.Status, + Error: flow.Error, + } + if !flow.ExpiresAt.IsZero() { + resp.ExpiresAt = flow.ExpiresAt.Format(time.RFC3339) + } + if flow.Method == oauthMethodDeviceCode { + resp.UserCode = flow.UserCode + resp.VerifyURL = flow.VerifyURL + resp.Interval = flow.Interval + } + return resp +} + +func newOAuthFlowID() string { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return fmt.Sprintf("oauth_%d", time.Now().UnixNano()) + } + return hex.EncodeToString(buf) +} + +func (h *Handler) storeOAuthFlow(flow *oauthFlow) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + h.gcOAuthFlowsLocked(now) + h.oauthFlows[flow.ID] = flow + if flow.OAuthState != "" { + h.oauthState[flow.OAuthState] = flow.ID + } +} + +func (h *Handler) getOAuthFlow(flowID string) (*oauthFlow, bool) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + h.gcOAuthFlowsLocked(now) + flow, ok := h.oauthFlows[flowID] + if !ok { + return nil, false + } + cp := *flow + return &cp, true +} + +func (h *Handler) getOAuthFlowByState(state string) (*oauthFlow, bool) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + h.gcOAuthFlowsLocked(now) + flowID, ok := h.oauthState[state] + if !ok { + return nil, false + } + flow, ok := h.oauthFlows[flowID] + if !ok { + delete(h.oauthState, state) + return nil, false + } + cp := *flow + return &cp, true +} + +func (h *Handler) setOAuthFlowSuccess(flowID string) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + flow, ok := h.oauthFlows[flowID] + if !ok { + return + } + flow.Status = oauthFlowSuccess + flow.Error = "" + flow.UpdatedAt = now + if flow.OAuthState != "" { + delete(h.oauthState, flow.OAuthState) + } +} + +func (h *Handler) setOAuthFlowError(flowID, errMsg string) { + now := oauthNow() + h.oauthMu.Lock() + defer h.oauthMu.Unlock() + + flow, ok := h.oauthFlows[flowID] + if !ok { + return + } + flow.Status = oauthFlowError + flow.Error = errMsg + flow.UpdatedAt = now + if flow.OAuthState != "" { + delete(h.oauthState, flow.OAuthState) + } +} + +func (h *Handler) gcOAuthFlowsLocked(now time.Time) { + for id, flow := range h.oauthFlows { + if flow.Status == oauthFlowPending && !flow.ExpiresAt.IsZero() && now.After(flow.ExpiresAt) { + flow.Status = oauthFlowExpired + flow.Error = "flow expired" + flow.UpdatedAt = now + if flow.OAuthState != "" { + delete(h.oauthState, flow.OAuthState) + } + } + + if flow.Status != oauthFlowPending && now.Sub(flow.UpdatedAt) > oauthTerminalFlowGC { + if flow.OAuthState != "" { + delete(h.oauthState, flow.OAuthState) + } + delete(h.oauthFlows, id) + } + } +} + +func (h *Handler) persistCredentialAndConfig(provider, authMethod string, cred *auth.AuthCredential) error { + if cred == nil { + return fmt.Errorf("empty credential") + } + + cp := *cred + cp.Provider = provider + if cp.AuthMethod == "" { + cp.AuthMethod = authMethod + } + + if provider == oauthProviderGoogleAntigravity { + if cp.Email == "" { + email, err := oauthFetchGoogleUserEmailFunc(cp.AccessToken) + if err != nil { + log.Printf("oauth warning: could not fetch google email: %v", err) + } else { + cp.Email = email + } + } + if cp.ProjectID == "" { + projectID, err := oauthFetchAntigravityProject(cp.AccessToken) + if err != nil { + log.Printf("oauth warning: could not fetch antigravity project id: %v", err) + } else { + cp.ProjectID = projectID + } + } + } + + if err := oauthSetCredential(provider, &cp); err != nil { + return fmt.Errorf("saving credential: %w", err) + } + if err := h.syncProviderAuthMethod(provider, authMethod); err != nil { + return fmt.Errorf("syncing provider auth config: %w", err) + } + return nil +} + +func (h *Handler) syncProviderAuthMethod(provider, authMethod string) error { + cfg, err := oauthLoadConfig(h.configPath) + if err != nil { + return err + } + + switch provider { + case oauthProviderOpenAI: + cfg.Providers.OpenAI.AuthMethod = authMethod + case oauthProviderAnthropic: + cfg.Providers.Anthropic.AuthMethod = authMethod + case oauthProviderGoogleAntigravity: + cfg.Providers.Antigravity.AuthMethod = authMethod + default: + return fmt.Errorf("unsupported provider %q", provider) + } + + found := false + for i := range cfg.ModelList { + if modelBelongsToProvider(provider, cfg.ModelList[i].Model) { + cfg.ModelList[i].AuthMethod = authMethod + found = true + } + } + + if !found && authMethod != "" { + cfg.ModelList = append(cfg.ModelList, defaultModelConfigForProvider(provider, authMethod)) + } + + return oauthSaveConfig(h.configPath, cfg) +} + +func modelBelongsToProvider(provider, model string) bool { + lower := strings.ToLower(strings.TrimSpace(model)) + switch provider { + case oauthProviderOpenAI: + return lower == "openai" || strings.HasPrefix(lower, "openai/") + case oauthProviderAnthropic: + return lower == "anthropic" || strings.HasPrefix(lower, "anthropic/") + case oauthProviderGoogleAntigravity: + return lower == "antigravity" || + lower == "google-antigravity" || + strings.HasPrefix(lower, "antigravity/") || + strings.HasPrefix(lower, "google-antigravity/") + default: + return false + } +} + +func defaultModelConfigForProvider(provider, authMethod string) config.ModelConfig { + switch provider { + case oauthProviderOpenAI: + return config.ModelConfig{ + ModelName: "gpt-5.4", + Model: "openai/gpt-5.4", + AuthMethod: authMethod, + } + case oauthProviderAnthropic: + return config.ModelConfig{ + ModelName: "claude-sonnet-4.6", + Model: "anthropic/claude-sonnet-4.6", + AuthMethod: authMethod, + } + case oauthProviderGoogleAntigravity: + return config.ModelConfig{ + ModelName: "gemini-flash", + Model: "antigravity/gemini-3-flash", + AuthMethod: authMethod, + } + default: + return config.ModelConfig{} + } +} + +func fetchGoogleUserEmail(accessToken string) (string, error) { + req, err := http.NewRequest(http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("userinfo request failed: %s", string(body)) + } + + var userInfo struct { + Email string `json:"email"` + } + if err := json.Unmarshal(body, &userInfo); err != nil { + return "", err + } + if userInfo.Email == "" { + return "", fmt.Errorf("empty email in userinfo response") + } + return userInfo.Email, nil +} diff --git a/web/backend/api/oauth_test.go b/web/backend/api/oauth_test.go new file mode 100644 index 000000000..7d63abbd4 --- /dev/null +++ b/web/backend/api/oauth_test.go @@ -0,0 +1,293 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestOAuthLoginRejectsUnsupportedMethod(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/oauth/login", + strings.NewReader(`{"provider":"anthropic","method":"browser"}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusBadRequest, rec.Body.String()) + } +} + +func TestOAuthBrowserFlowCreatedAndQueried(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + oauthGeneratePKCE = func() (auth.PKCECodes, error) { + return auth.PKCECodes{CodeVerifier: "verifier-1", CodeChallenge: "challenge-1"}, nil + } + oauthGenerateState = func() (string, error) { return "state-1", nil } + oauthBuildAuthorizeURL = func(cfg auth.OAuthProviderConfig, pkce auth.PKCECodes, state, redirectURI string) string { + return "https://example.com/authorize?state=" + state + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPost, + "/api/oauth/login", + strings.NewReader(`{"provider":"openai","method":"browser"}`), + ) + req.Host = "localhost:18800" + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var loginResp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &loginResp); err != nil { + t.Fatalf("unmarshal login response: %v", err) + } + flowID, _ := loginResp["flow_id"].(string) + if flowID == "" { + t.Fatalf("flow_id is empty: %v", loginResp) + } + if loginResp["auth_url"] != "https://example.com/authorize?state=state-1" { + t.Fatalf("unexpected auth_url: %v", loginResp["auth_url"]) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/"+flowID, nil) + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("flow status code = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) + } + var flowResp oauthFlowResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &flowResp); err != nil { + t.Fatalf("unmarshal flow response: %v", err) + } + if flowResp.Status != oauthFlowPending { + t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowPending) + } + if flowResp.Method != oauthMethodBrowser { + t.Fatalf("flow method = %q, want %q", flowResp.Method, oauthMethodBrowser) + } +} + +func TestOAuthFlowExpiresWhenQueried(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + now := time.Date(2026, 3, 6, 12, 0, 0, 0, time.UTC) + oauthNow = func() time.Time { return now } + + h := NewHandler(configPath) + h.storeOAuthFlow(&oauthFlow{ + ID: "expired-flow", + Provider: oauthProviderOpenAI, + Method: oauthMethodBrowser, + Status: oauthFlowPending, + CreatedAt: now.Add(-20 * time.Minute), + UpdatedAt: now.Add(-20 * time.Minute), + ExpiresAt: now.Add(-1 * time.Minute), + }) + + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/oauth/flows/expired-flow", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + var flowResp oauthFlowResponse + if err := json.Unmarshal(rec.Body.Bytes(), &flowResp); err != nil { + t.Fatalf("unmarshal flow response: %v", err) + } + if flowResp.Status != oauthFlowExpired { + t.Fatalf("flow status = %q, want %q", flowResp.Status, oauthFlowExpired) + } +} + +func TestOAuthCallbackUnknownState(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/oauth/callback?state=unknown&code=abc", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } + if !strings.Contains(rec.Body.String(), "OAuth flow not found") { + t.Fatalf("unexpected body: %s", rec.Body.String()) + } +} + +func TestOAuthLogoutClearsCredentialAndConfig(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + resetOAuthHooks(t) + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + cfg.Providers.OpenAI.AuthMethod = "oauth" + cfg.ModelList = append(cfg.ModelList, config.ModelConfig{ + ModelName: "gpt-5.4", + Model: "openai/gpt-5.4", + AuthMethod: "oauth", + }) + if err = config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + if err = auth.SetCredential(oauthProviderOpenAI, &auth.AuthCredential{ + AccessToken: "token-before-logout", + Provider: oauthProviderOpenAI, + AuthMethod: "oauth", + }); err != nil { + t.Fatalf("SetCredential error: %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/oauth/logout", bytes.NewBufferString(`{"provider":"openai"}`)) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + cred, err := auth.GetCredential(oauthProviderOpenAI) + if err != nil { + t.Fatalf("GetCredential error: %v", err) + } + if cred != nil { + t.Fatalf("expected credential deleted, got %#v", cred) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig error: %v", err) + } + if updated.Providers.OpenAI.AuthMethod != "" { + t.Fatalf("providers.openai.auth_method = %q, want empty", updated.Providers.OpenAI.AuthMethod) + } + for _, m := range updated.ModelList { + if strings.HasPrefix(m.Model, "openai/") && m.AuthMethod != "" { + t.Fatalf("openai model auth_method = %q, want empty", m.AuthMethod) + } + } +} + +func setupOAuthTestEnv(t *testing.T) (string, func()) { + t.Helper() + + tmp := t.TempDir() + oldHome := os.Getenv("HOME") + oldPicoHome := os.Getenv("PICOCLAW_HOME") + + if err := os.Setenv("HOME", tmp); err != nil { + t.Fatalf("set HOME: %v", err) + } + if err := os.Setenv("PICOCLAW_HOME", filepath.Join(tmp, ".picoclaw")); err != nil { + t.Fatalf("set PICOCLAW_HOME: %v", err) + } + + cfg := config.DefaultConfig() + cfg.ModelList = []config.ModelConfig{{ + ModelName: "custom-default", + Model: "openai/gpt-4o", + APIKey: "sk-default", + }} + cfg.Agents.Defaults.ModelName = "custom-default" + + configPath := filepath.Join(tmp, "config.json") + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig error: %v", err) + } + + cleanup := func() { + _ = os.Setenv("HOME", oldHome) + if oldPicoHome == "" { + _ = os.Unsetenv("PICOCLAW_HOME") + } else { + _ = os.Setenv("PICOCLAW_HOME", oldPicoHome) + } + } + return configPath, cleanup +} + +func resetOAuthHooks(t *testing.T) { + t.Helper() + + origNow := oauthNow + origGeneratePKCE := oauthGeneratePKCE + origGenerateState := oauthGenerateState + origBuildAuthorizeURL := oauthBuildAuthorizeURL + origRequestDeviceCode := oauthRequestDeviceCode + origPollDeviceCodeOnce := oauthPollDeviceCodeOnce + origExchangeCodeForTokens := oauthExchangeCodeForTokens + origGetCredential := oauthGetCredential + origSetCredential := oauthSetCredential + origDeleteCredential := oauthDeleteCredential + origLoadConfig := oauthLoadConfig + origSaveConfig := oauthSaveConfig + origFetchProject := oauthFetchAntigravityProject + origFetchGoogleEmail := oauthFetchGoogleUserEmailFunc + + t.Cleanup(func() { + oauthNow = origNow + oauthGeneratePKCE = origGeneratePKCE + oauthGenerateState = origGenerateState + oauthBuildAuthorizeURL = origBuildAuthorizeURL + oauthRequestDeviceCode = origRequestDeviceCode + oauthPollDeviceCodeOnce = origPollDeviceCodeOnce + oauthExchangeCodeForTokens = origExchangeCodeForTokens + oauthGetCredential = origGetCredential + oauthSetCredential = origSetCredential + oauthDeleteCredential = origDeleteCredential + oauthLoadConfig = origLoadConfig + oauthSaveConfig = origSaveConfig + oauthFetchAntigravityProject = origFetchProject + oauthFetchGoogleUserEmailFunc = origFetchGoogleEmail + }) +} diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go new file mode 100644 index 000000000..a4590dcde --- /dev/null +++ b/web/backend/api/pico.go @@ -0,0 +1,143 @@ +package api + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux. +func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken) + mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) + mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) +} + +// handleGetPicoToken returns the current WS token and URL for the frontend. +// +// GET /api/pico/token +func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + wsURL := h.buildWsURL(r, cfg) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "token": cfg.Channels.Pico.Token, + "ws_url": wsURL, + "enabled": cfg.Channels.Pico.Enabled, + }) +} + +// handleRegenPicoToken generates a new Pico WebSocket token and saves it. +// +// POST /api/pico/token +func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + token := generateSecureToken() + cfg.Channels.Pico.Token = token + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + wsURL := h.buildWsURL(r, cfg) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "token": token, + "ws_url": wsURL, + }) +} + +// ensurePicoChannel checks if the Pico Channel is properly configured and +// enables it with sensible defaults if not. Returns true if config was changed. +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) + } + + changed := false + + if !cfg.Channels.Pico.Enabled { + cfg.Channels.Pico.Enabled = true + changed = true + } + + if cfg.Channels.Pico.Token == "" { + cfg.Channels.Pico.Token = generateSecureToken() + changed = true + } + + if !cfg.Channels.Pico.AllowTokenQuery { + cfg.Channels.Pico.AllowTokenQuery = true + changed = true + } + + // Make sure origins are allowed (frontend might be running on a different port like 5173 during dev) + if len(cfg.Channels.Pico.AllowOrigins) == 0 { + cfg.Channels.Pico.AllowOrigins = []string{"*"} + changed = true + } + + if changed { + if err := config.SaveConfig(h.configPath, cfg); err != nil { + return false, fmt.Errorf("failed to save config: %w", err) + } + } + + return changed, nil +} + +// handlePicoSetup automatically configures everything needed for the Pico Channel to work. +// +// POST /api/pico/setup +func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) { + changed, err := h.ensurePicoChannel() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + wsURL := h.buildWsURL(r, cfg) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "token": cfg.Channels.Pico.Token, + "ws_url": wsURL, + "enabled": true, + "changed": changed, + }) +} + +// generateSecureToken creates a random 32-character hex string. +func generateSecureToken() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + // Fallback to something pseudo-random if crypto/rand fails + return fmt.Sprintf("pico_%x", time.Now().UnixNano()) + } + return hex.EncodeToString(b) +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go new file mode 100644 index 000000000..5f081dee9 --- /dev/null +++ b/web/backend/api/router.go @@ -0,0 +1,72 @@ +package api + +import ( + "net/http" + "sync" + + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +// Handler serves HTTP API requests. +type Handler struct { + configPath string + serverPort int + serverPublic bool + serverPublicExplicit bool + serverCIDRs []string + oauthMu sync.Mutex + oauthFlows map[string]*oauthFlow + oauthState map[string]string +} + +// NewHandler creates an instance of the API handler. +func NewHandler(configPath string) *Handler { + return &Handler{ + configPath: configPath, + serverPort: launcherconfig.DefaultPort, + oauthFlows: make(map[string]*oauthFlow), + oauthState: make(map[string]string), + } +} + +// SetServerOptions stores current backend listen options for fallback behavior. +func (h *Handler) SetServerOptions(port int, public bool, publicExplicit bool, allowedCIDRs []string) { + h.serverPort = port + h.serverPublic = public + h.serverPublicExplicit = publicExplicit + h.serverCIDRs = append([]string(nil), allowedCIDRs...) +} + +// RegisterRoutes binds all API endpoint handlers to the ServeMux. +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + // Config CRUD + h.registerConfigRoutes(mux) + + // Pico Channel (WebSocket chat) + h.registerPicoRoutes(mux) + + // Gateway process lifecycle + h.registerGatewayRoutes(mux) + + // Session history + h.registerSessionRoutes(mux) + + // OAuth login and credential management + h.registerOAuthRoutes(mux) + + // Model list management + h.registerModelRoutes(mux) + + // Channel catalog (for frontend navigation/config pages) + h.registerChannelRoutes(mux) + + // Skills and tools support/actions + h.registerSkillRoutes(mux) + h.registerToolRoutes(mux) + + // OS startup / launch-at-login + h.registerStartupRoutes(mux) + + // Launcher service parameters (port/public) + h.registerLauncherConfigRoutes(mux) +} diff --git a/web/backend/api/session.go b/web/backend/api/session.go new file mode 100644 index 000000000..42d451a05 --- /dev/null +++ b/web/backend/api/session.go @@ -0,0 +1,506 @@ +package api + +import ( + "bufio" + "encoding/json" + "errors" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// registerSessionRoutes binds session list and detail endpoints to the ServeMux. +func (h *Handler) registerSessionRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/sessions", h.handleListSessions) + mux.HandleFunc("GET /api/sessions/{id}", h.handleGetSession) + mux.HandleFunc("DELETE /api/sessions/{id}", h.handleDeleteSession) +} + +// sessionFile mirrors the on-disk session JSON structure from pkg/session. +type sessionFile struct { + Key string `json:"key"` + Messages []providers.Message `json:"messages"` + Summary string `json:"summary,omitempty"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` +} + +// sessionListItem is a lightweight summary returned by GET /api/sessions. +type sessionListItem struct { + ID string `json:"id"` + Title string `json:"title"` + Preview string `json:"preview"` + MessageCount int `json:"message_count"` + Created string `json:"created"` + Updated string `json:"updated"` +} + +type sessionMetaFile struct { + Key string `json:"key"` + Summary string `json:"summary"` + Skip int `json:"skip"` + Count int `json:"count"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// picoSessionPrefix is the key prefix used by the gateway's routing for Pico +// channel sessions. The full key format is: +// +// agent:main:pico:direct:pico: +// +// The sanitized filename replaces ':' with '_', so on disk it becomes: +// +// agent_main_pico_direct_pico_.json +const ( + picoSessionPrefix = "agent:main:pico:direct:pico:" + sanitizedPicoSessionPrefix = "agent_main_pico_direct_pico_" + maxSessionJSONLLineSize = 10 * 1024 * 1024 // 10 MB + maxSessionTitleRunes = 60 +) + +// extractPicoSessionID extracts the session UUID from a full session key. +// Returns the UUID and true if the key matches the Pico session pattern. +func extractPicoSessionID(key string) (string, bool) { + if strings.HasPrefix(key, picoSessionPrefix) { + return strings.TrimPrefix(key, picoSessionPrefix), true + } + return "", false +} + +func extractPicoSessionIDFromSanitizedKey(key string) (string, bool) { + if strings.HasPrefix(key, sanitizedPicoSessionPrefix) { + return strings.TrimPrefix(key, sanitizedPicoSessionPrefix), true + } + return "", false +} + +func sanitizeSessionKey(key string) string { + return strings.ReplaceAll(key, ":", "_") +} + +func (h *Handler) readLegacySession(dir, sessionID string) (sessionFile, error) { + path := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)+".json") + data, err := os.ReadFile(path) + if err != nil { + return sessionFile{}, err + } + + var sess sessionFile + if err := json.Unmarshal(data, &sess); err != nil { + return sessionFile{}, err + } + return sess, nil +} + +func (h *Handler) readSessionMeta(path, sessionKey string) (sessionMetaFile, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return sessionMetaFile{Key: sessionKey}, nil + } + if err != nil { + return sessionMetaFile{}, err + } + + var meta sessionMetaFile + if err := json.Unmarshal(data, &meta); err != nil { + return sessionMetaFile{}, err + } + if meta.Key == "" { + meta.Key = sessionKey + } + return meta, nil +} + +func (h *Handler) readSessionMessages(path string, skip int) ([]providers.Message, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + msgs := make([]providers.Message, 0) + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 0, 64*1024), maxSessionJSONLLineSize) + + seen := 0 + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + seen++ + if seen <= skip { + continue + } + + var msg providers.Message + if err := json.Unmarshal(line, &msg); err != nil { + continue + } + msgs = append(msgs, msg) + } + if err := scanner.Err(); err != nil { + return nil, err + } + return msgs, nil +} + +func (h *Handler) readJSONLSession(dir, sessionID string) (sessionFile, error) { + sessionKey := picoSessionPrefix + sessionID + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + jsonlPath := base + ".jsonl" + metaPath := base + ".meta.json" + + meta, err := h.readSessionMeta(metaPath, sessionKey) + if err != nil { + return sessionFile{}, err + } + + messages, err := h.readSessionMessages(jsonlPath, meta.Skip) + if err != nil { + return sessionFile{}, err + } + + updated := meta.UpdatedAt + created := meta.CreatedAt + if created.IsZero() || updated.IsZero() { + if info, statErr := os.Stat(jsonlPath); statErr == nil { + if created.IsZero() { + created = info.ModTime() + } + if updated.IsZero() { + updated = info.ModTime() + } + } + } + + return sessionFile{ + Key: meta.Key, + Messages: messages, + Summary: meta.Summary, + Created: created, + Updated: updated, + }, nil +} + +func buildSessionListItem(sessionID string, sess sessionFile) sessionListItem { + preview := "" + for _, msg := range sess.Messages { + if msg.Role == "user" && strings.TrimSpace(msg.Content) != "" { + preview = msg.Content + break + } + } + title := strings.TrimSpace(sess.Summary) + if title == "" { + title = preview + } + + title = truncateRunes(title, maxSessionTitleRunes) + preview = truncateRunes(preview, maxSessionTitleRunes) + + if preview == "" { + preview = "(empty)" + } + if title == "" { + title = preview + } + + validMessageCount := 0 + for _, msg := range sess.Messages { + if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { + validMessageCount++ + } + } + + return sessionListItem{ + ID: sessionID, + Title: title, + Preview: preview, + MessageCount: validMessageCount, + Created: sess.Created.Format(time.RFC3339), + Updated: sess.Updated.Format(time.RFC3339), + } +} + +func isEmptySession(sess sessionFile) bool { + return len(sess.Messages) == 0 && strings.TrimSpace(sess.Summary) == "" +} + +func truncateRunes(s string, maxLen int) string { + if maxLen <= 0 { + return "" + } + runes := []rune(strings.TrimSpace(s)) + if len(runes) <= maxLen { + return string(runes) + } + return string(runes[:maxLen]) + "..." +} + +// sessionsDir resolves the path to the gateway's session storage directory. +// It reads the workspace from config, falling back to ~/.picoclaw/workspace. +func (h *Handler) sessionsDir() (string, error) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + return "", err + } + + workspace := cfg.Agents.Defaults.Workspace + if workspace == "" { + home, _ := os.UserHomeDir() + workspace = filepath.Join(home, ".picoclaw", "workspace") + } + + // Expand ~ prefix + if len(workspace) > 0 && workspace[0] == '~' { + home, _ := os.UserHomeDir() + if len(workspace) > 1 && workspace[1] == '/' { + workspace = home + workspace[1:] + } else { + workspace = home + } + } + + return filepath.Join(workspace, "sessions"), nil +} + +// handleListSessions returns a list of Pico session summaries. +// +// GET /api/sessions +func (h *Handler) handleListSessions(w http.ResponseWriter, r *http.Request) { + dir, err := h.sessionsDir() + if err != nil { + http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) + return + } + + entries, err := os.ReadDir(dir) + if err != nil { + // Directory doesn't exist yet = no sessions + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]sessionListItem{}) + return + } + + items := []sessionListItem{} + seen := make(map[string]struct{}) + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + name := entry.Name() + var ( + sessionID string + sess sessionFile + loadErr error + ok bool + ) + + switch { + case strings.HasSuffix(name, ".jsonl"): + sessionID, ok = extractPicoSessionIDFromSanitizedKey(strings.TrimSuffix(name, ".jsonl")) + if !ok { + continue + } + sess, loadErr = h.readJSONLSession(dir, sessionID) + if loadErr == nil && isEmptySession(sess) { + continue + } + case strings.HasSuffix(name, ".meta.json"): + continue + case filepath.Ext(name) == ".json": + base := strings.TrimSuffix(name, ".json") + if _, statErr := os.Stat(filepath.Join(dir, base+".jsonl")); statErr == nil { + if jsonlSessionID, found := extractPicoSessionIDFromSanitizedKey(base); found { + if jsonlSess, jsonlErr := h.readJSONLSession( + dir, + jsonlSessionID, + ); jsonlErr == nil && + !isEmptySession(jsonlSess) { + continue + } + } + } + data, err := os.ReadFile(filepath.Join(dir, name)) + if err != nil { + continue + } + if err := json.Unmarshal(data, &sess); err != nil { + continue + } + if isEmptySession(sess) { + continue + } + sessionID, ok = extractPicoSessionID(sess.Key) + if !ok { + continue + } + if _, exists := seen[sessionID]; exists { + continue + } + default: + continue + } + + if loadErr != nil { + continue + } + if _, exists := seen[sessionID]; exists { + continue + } + + seen[sessionID] = struct{}{} + items = append(items, buildSessionListItem(sessionID, sess)) + } + + // Sort by updated descending (most recent first) + sort.Slice(items, func(i, j int) bool { + return items[i].Updated > items[j].Updated + }) + + // Pagination parameters + offsetStr := r.URL.Query().Get("offset") + limitStr := r.URL.Query().Get("limit") + + offset := 0 + limit := 20 // Default limit + + if val, err := strconv.Atoi(offsetStr); err == nil && val >= 0 { + offset = val + } + if val, err := strconv.Atoi(limitStr); err == nil && val > 0 { + limit = val + } + + totalItems := len(items) + + end := offset + limit + if offset >= totalItems { + items = []sessionListItem{} // Out of bounds, return empty + } else { + if end > totalItems { + end = totalItems + } + items = items[offset:end] + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(items) +} + +// handleGetSession returns the full message history for a specific session. +// +// GET /api/sessions/{id} +func (h *Handler) handleGetSession(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("id") + if sessionID == "" { + http.Error(w, "missing session id", http.StatusBadRequest) + return + } + + dir, err := h.sessionsDir() + if err != nil { + http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) + return + } + + sess, err := h.readJSONLSession(dir, sessionID) + if err == nil && isEmptySession(sess) { + err = os.ErrNotExist + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + sess, err = h.readLegacySession(dir, sessionID) + if err == nil && isEmptySession(sess) { + err = os.ErrNotExist + } + } + if err != nil { + if errors.Is(err, os.ErrNotExist) { + http.Error(w, "session not found", http.StatusNotFound) + } else { + http.Error(w, "failed to parse session", http.StatusInternalServerError) + } + return + } + } + + // Convert to a simpler format for the frontend + type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` + } + + messages := make([]chatMessage, 0, len(sess.Messages)) + for _, msg := range sess.Messages { + // Only include user and assistant messages that have actual content + if (msg.Role == "user" || msg.Role == "assistant") && strings.TrimSpace(msg.Content) != "" { + messages = append(messages, chatMessage{ + Role: msg.Role, + Content: msg.Content, + }) + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "id": sessionID, + "messages": messages, + "summary": sess.Summary, + "created": sess.Created.Format(time.RFC3339), + "updated": sess.Updated.Format(time.RFC3339), + }) +} + +// handleDeleteSession deletes a specific session. +// +// DELETE /api/sessions/{id} +func (h *Handler) handleDeleteSession(w http.ResponseWriter, r *http.Request) { + sessionID := r.PathValue("id") + if sessionID == "" { + http.Error(w, "missing session id", http.StatusBadRequest) + return + } + + dir, err := h.sessionsDir() + if err != nil { + http.Error(w, "failed to resolve sessions directory", http.StatusInternalServerError) + return + } + + base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+sessionID)) + jsonlPath := base + ".jsonl" + metaPath := base + ".meta.json" + legacyPath := base + ".json" + + removed := false + for _, path := range []string{jsonlPath, metaPath, legacyPath} { + if err := os.Remove(path); err != nil { + if os.IsNotExist(err) { + continue + } + http.Error(w, "failed to delete session", http.StatusInternalServerError) + return + } + removed = true + } + + if !removed { + http.Error(w, "session not found", http.StatusNotFound) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/web/backend/api/session_test.go b/web/backend/api/session_test.go new file mode 100644 index 000000000..21ef5b5b8 --- /dev/null +++ b/web/backend/api/session_test.go @@ -0,0 +1,322 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/memory" + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/session" +) + +func sessionsTestDir(t *testing.T, configPath string) string { + t.Helper() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + dir := filepath.Join(cfg.Agents.Defaults.Workspace, "sessions") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + return dir +} + +func TestHandleListSessions_JSONLStorage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "history-jsonl" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "Explain why the history API is empty after migration.", + }); err != nil { + t.Fatalf("AddFullMessage(user) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "assistant", + Content: "Because the API still reads only legacy JSON session files.", + }); err != nil { + t.Fatalf("AddFullMessage(assistant) error = %v", err) + } + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "tool", + Content: "ignored", + }); err != nil { + t.Fatalf("AddFullMessage(tool) error = %v", err) + } + if err := store.SetSummary(nil, sessionKey, "JSONL-backed session"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + if items[0].ID != "history-jsonl" { + t.Fatalf("items[0].ID = %q, want %q", items[0].ID, "history-jsonl") + } + if items[0].MessageCount != 2 { + t.Fatalf("items[0].MessageCount = %d, want 2", items[0].MessageCount) + } + if items[0].Title != "JSONL-backed session" { + t.Fatalf("items[0].Title = %q, want %q", items[0].Title, "JSONL-backed session") + } + if items[0].Preview != "Explain why the history API is empty after migration." { + t.Fatalf("items[0].Preview = %q", items[0].Preview) + } +} + +func TestHandleListSessions_TitleUsesTrimmedSummary(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "summary-title" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "fallback preview", + }); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + if err := store.SetSummary( + nil, + sessionKey, + " This summary is intentionally longer than sixty characters so it must be truncated in the history menu. ", + ); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(rec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(items) != 1 { + t.Fatalf("len(items) = %d, want 1", len(items)) + } + expectedTitle := truncateRunes( + "This summary is intentionally longer than sixty characters so it must be truncated in the history menu.", + maxSessionTitleRunes, + ) + if items[0].Title != expectedTitle { + t.Fatalf("items[0].Title = %q", items[0].Title) + } + if items[0].Preview != "fallback preview" { + t.Fatalf("items[0].Preview = %q, want %q", items[0].Preview, "fallback preview") + } +} + +func TestHandleGetSession_JSONLStorage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "detail-jsonl" + for _, msg := range []providers.Message{ + {Role: "user", Content: "first"}, + {Role: "assistant", Content: "second"}, + {Role: "tool", Content: "ignored"}, + } { + if err := store.AddFullMessage(nil, sessionKey, msg); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + } + if err := store.SetSummary(nil, sessionKey, "detail summary"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/detail-jsonl", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp struct { + ID string `json:"id"` + Summary string `json:"summary"` + Messages []struct { + Role string `json:"role"` + Content string `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.ID != "detail-jsonl" { + t.Fatalf("resp.ID = %q, want %q", resp.ID, "detail-jsonl") + } + if resp.Summary != "detail summary" { + t.Fatalf("resp.Summary = %q, want %q", resp.Summary, "detail summary") + } + if len(resp.Messages) != 2 { + t.Fatalf("len(resp.Messages) = %d, want 2", len(resp.Messages)) + } + if resp.Messages[0].Role != "user" || resp.Messages[0].Content != "first" { + t.Fatalf("first message = %#v, want user/first", resp.Messages[0]) + } + if resp.Messages[1].Role != "assistant" || resp.Messages[1].Content != "second" { + t.Fatalf("second message = %#v, want assistant/second", resp.Messages[1]) + } +} + +func TestHandleDeleteSession_JSONLStorage(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + store, err := memory.NewJSONLStore(dir) + if err != nil { + t.Fatalf("NewJSONLStore() error = %v", err) + } + + sessionKey := picoSessionPrefix + "delete-jsonl" + if err := store.AddFullMessage(nil, sessionKey, providers.Message{ + Role: "user", + Content: "delete me", + }); err != nil { + t.Fatalf("AddFullMessage() error = %v", err) + } + if err := store.SetSummary(nil, sessionKey, "delete summary"); err != nil { + t.Fatalf("SetSummary() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/sessions/delete-jsonl", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusNoContent, rec.Body.String()) + } + + base := filepath.Join(dir, sanitizeSessionKey(sessionKey)) + for _, path := range []string{base + ".jsonl", base + ".meta.json"} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be removed, stat err = %v", path, err) + } + } +} + +func TestHandleGetSession_LegacyJSONFallback(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + manager := session.NewSessionManager(dir) + sessionKey := picoSessionPrefix + "legacy-json" + manager.AddMessage(sessionKey, "user", "legacy user") + manager.AddMessage(sessionKey, "assistant", "legacy assistant") + if err := manager.Save(sessionKey); err != nil { + t.Fatalf("Save() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/sessions/legacy-json", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestHandleSessions_FiltersEmptyJSONLFiles(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + dir := sessionsTestDir(t, configPath) + base := filepath.Join(dir, sanitizeSessionKey(picoSessionPrefix+"empty-jsonl")) + if err := os.WriteFile(base+".jsonl", []byte{}, 0o644); err != nil { + t.Fatalf("WriteFile(jsonl) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + listRec := httptest.NewRecorder() + listReq := httptest.NewRequest(http.MethodGet, "/api/sessions", nil) + mux.ServeHTTP(listRec, listReq) + + if listRec.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", listRec.Code, http.StatusOK, listRec.Body.String()) + } + + var items []sessionListItem + if err := json.Unmarshal(listRec.Body.Bytes(), &items); err != nil { + t.Fatalf("Unmarshal(list) error = %v", err) + } + if len(items) != 0 { + t.Fatalf("len(items) = %d, want 0", len(items)) + } + + detailRec := httptest.NewRecorder() + detailReq := httptest.NewRequest(http.MethodGet, "/api/sessions/empty-jsonl", nil) + mux.ServeHTTP(detailRec, detailReq) + + if detailRec.Code != http.StatusNotFound { + t.Fatalf("detail status = %d, want %d, body=%s", detailRec.Code, http.StatusNotFound, detailRec.Body.String()) + } +} diff --git a/web/backend/api/skills.go b/web/backend/api/skills.go new file mode 100644 index 000000000..936074fee --- /dev/null +++ b/web/backend/api/skills.go @@ -0,0 +1,331 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/skills" +) + +type skillSupportResponse struct { + Skills []skills.SkillInfo `json:"skills"` +} + +type skillDetailResponse struct { + Name string `json:"name"` + Path string `json:"path"` + Source string `json:"source"` + Description string `json:"description"` + Content string `json:"content"` +} + +var ( + skillNameSanitizer = regexp.MustCompile(`[^a-z0-9-]+`) + importedSkillFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) + skillFrontmatterStripper = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`) +) + +func (h *Handler) registerSkillRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/skills", h.handleListSkills) + mux.HandleFunc("GET /api/skills/{name}", h.handleGetSkill) + mux.HandleFunc("POST /api/skills/import", h.handleImportSkill) + mux.HandleFunc("DELETE /api/skills/{name}", h.handleDeleteSkill) +} + +func (h *Handler) handleListSkills(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + loader := newSkillsLoader(cfg.WorkspacePath()) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skillSupportResponse{ + Skills: loader.ListSkills(), + }) +} + +func (h *Handler) handleGetSkill(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + loader := newSkillsLoader(cfg.WorkspacePath()) + name := r.PathValue("name") + allSkills := loader.ListSkills() + + for _, skill := range allSkills { + if skill.Name != name { + continue + } + + content, err := loadSkillContent(skill.Path) + if err != nil { + http.Error(w, "Skill content not found", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skillDetailResponse{ + Name: skill.Name, + Path: skill.Path, + Source: skill.Source, + Description: skill.Description, + Content: content, + }) + return + } + + http.Error(w, "Skill not found", http.StatusNotFound) +} + +func (h *Handler) handleImportSkill(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + err = r.ParseMultipartForm(2 << 20) + if err != nil { + http.Error(w, fmt.Sprintf("Invalid multipart form: %v", err), http.StatusBadRequest) + return + } + + uploadedFile, fileHeader, err := r.FormFile("file") + if err != nil { + http.Error(w, "file is required", http.StatusBadRequest) + return + } + defer uploadedFile.Close() + + content, err := io.ReadAll(io.LimitReader(uploadedFile, (1<<20)+1)) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to read file: %v", err), http.StatusBadRequest) + return + } + if len(content) > 1<<20 { + http.Error(w, "file exceeds 1MB limit", http.StatusBadRequest) + return + } + + skillName, err := normalizeImportedSkillName(fileHeader.Filename, content) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + content = normalizeImportedSkillContent(content, skillName) + + workspace := cfg.WorkspacePath() + skillDir := filepath.Join(workspace, "skills", skillName) + skillFile := filepath.Join(skillDir, "SKILL.md") + if _, err := os.Stat(skillDir); err == nil { + http.Error(w, "skill already exists", http.StatusConflict) + return + } + + if err := os.MkdirAll(skillDir, 0o755); err != nil { + http.Error(w, fmt.Sprintf("Failed to create skill directory: %v", err), http.StatusInternalServerError) + return + } + if err := os.WriteFile(skillFile, content, 0o644); err != nil { + http.Error(w, fmt.Sprintf("Failed to save skill: %v", err), http.StatusInternalServerError) + return + } + + loader := newSkillsLoader(workspace) + for _, skill := range loader.ListSkills() { + if skill.Path == skillFile || (skill.Name == skillName && skill.Source == "workspace") { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(skill) + return + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{ + "name": skillName, + "path": skillFile, + }) +} + +func (h *Handler) handleDeleteSkill(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + loader := newSkillsLoader(cfg.WorkspacePath()) + name := r.PathValue("name") + for _, skill := range loader.ListSkills() { + if skill.Name != name { + continue + } + if skill.Source != "workspace" { + http.Error(w, "only workspace skills can be deleted", http.StatusBadRequest) + return + } + if err := os.RemoveAll(filepath.Dir(skill.Path)); err != nil { + http.Error(w, fmt.Sprintf("Failed to delete skill: %v", err), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) + return + } + + http.Error(w, "Skill not found", http.StatusNotFound) +} + +func newSkillsLoader(workspace string) *skills.SkillsLoader { + return skills.NewSkillsLoader( + workspace, + filepath.Join(globalConfigDir(), "skills"), + builtinSkillsDir(), + ) +} + +func normalizeImportedSkillName(filename string, content []byte) (string, error) { + rawContent := strings.ReplaceAll(string(content), "\r\n", "\n") + rawContent = strings.ReplaceAll(rawContent, "\r", "\n") + metadata, _ := extractImportedSkillMetadata(rawContent) + + raw := strings.TrimSpace(metadata["name"]) + if raw == "" { + raw = strings.TrimSpace(strings.TrimSuffix(filepath.Base(filename), filepath.Ext(filename))) + } + raw = strings.ToLower(raw) + raw = strings.ReplaceAll(raw, "_", "-") + raw = strings.ReplaceAll(raw, " ", "-") + raw = skillNameSanitizer.ReplaceAllString(raw, "-") + raw = strings.Trim(raw, "-") + raw = strings.Join(strings.FieldsFunc(raw, func(r rune) bool { return r == '-' }), "-") + + if raw == "" { + return "", fmt.Errorf("skill name is required in frontmatter or filename") + } + if len(raw) > 64 { + return "", fmt.Errorf("skill name exceeds 64 characters") + } + matched, err := regexp.MatchString(`^[a-z0-9]+(-[a-z0-9]+)*$`, raw) + if err != nil || !matched { + return "", fmt.Errorf("skill name must be alphanumeric with hyphens") + } + return raw, nil +} + +func normalizeImportedSkillContent(content []byte, skillName string) []byte { + raw := strings.ReplaceAll(string(content), "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + + metadata, body := extractImportedSkillMetadata(raw) + description := strings.TrimSpace(metadata["description"]) + if description == "" { + description = inferImportedSkillDescription(body) + } + if description == "" { + description = "Imported skill" + } + if len(description) > 1024 { + description = strings.TrimSpace(description[:1024]) + } + + body = strings.TrimLeft(body, "\n") + var builder strings.Builder + builder.WriteString("---\n") + builder.WriteString("name: ") + builder.WriteString(skillName) + builder.WriteString("\n") + builder.WriteString("description: ") + builder.WriteString(description) + builder.WriteString("\n") + builder.WriteString("---\n\n") + builder.WriteString(body) + if !strings.HasSuffix(builder.String(), "\n") { + builder.WriteString("\n") + } + return []byte(builder.String()) +} + +func extractImportedSkillMetadata(raw string) (map[string]string, string) { + matches := importedSkillFrontmatter.FindStringSubmatch(raw) + if len(matches) != 2 { + return map[string]string{}, raw + } + meta := parseImportedSkillYAML(matches[1]) + body := importedSkillFrontmatter.ReplaceAllString(raw, "") + return meta, body +} + +func parseImportedSkillYAML(frontmatter string) map[string]string { + result := make(map[string]string) + for _, line := range strings.Split(frontmatter, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + result[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`) + } + return result +} + +func inferImportedSkillDescription(body string) string { + for _, line := range strings.Split(body, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + line = strings.TrimLeft(line, "#-*0123456789. ") + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + +func loadSkillContent(path string) (string, error) { + content, err := os.ReadFile(path) + if err != nil { + return "", err + } + return skillFrontmatterStripper.ReplaceAllString(string(content), ""), nil +} + +func globalConfigDir() string { + if home := os.Getenv("PICOCLAW_HOME"); home != "" { + return home + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".picoclaw") +} + +func builtinSkillsDir() string { + if path := os.Getenv("PICOCLAW_BUILTIN_SKILLS"); path != "" { + return path + } + wd, err := os.Getwd() + if err != nil { + return "" + } + return filepath.Join(wd, "skills") +} diff --git a/web/backend/api/skills_test.go b/web/backend/api/skills_test.go new file mode 100644 index 000000000..3289d5b33 --- /dev/null +++ b/web/backend/api/skills_test.go @@ -0,0 +1,336 @@ +package api + +import ( + "bytes" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleListSkills(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + if err := os.MkdirAll(filepath.Join(workspace, "skills", "workspace-skill"), 0o755); err != nil { + t.Fatalf("MkdirAll(workspace skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(workspace, "skills", "workspace-skill", "SKILL.md"), + []byte("---\nname: workspace-skill\ndescription: Workspace skill\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(workspace skill) error = %v", err) + } + + globalSkillDir := filepath.Join(globalConfigDir(), "skills", "global-skill") + if err := os.MkdirAll(globalSkillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(global skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(globalSkillDir, "SKILL.md"), + []byte("---\nname: global-skill\ndescription: Global skill\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(global skill) error = %v", err) + } + + builtinRoot := filepath.Join(t.TempDir(), "builtin-skills") + oldBuiltin := os.Getenv("PICOCLAW_BUILTIN_SKILLS") + if err := os.Setenv("PICOCLAW_BUILTIN_SKILLS", builtinRoot); err != nil { + t.Fatalf("Setenv(PICOCLAW_BUILTIN_SKILLS) error = %v", err) + } + defer func() { + if oldBuiltin == "" { + _ = os.Unsetenv("PICOCLAW_BUILTIN_SKILLS") + } else { + _ = os.Setenv("PICOCLAW_BUILTIN_SKILLS", oldBuiltin) + } + }() + + builtinSkillDir := filepath.Join(builtinRoot, "builtin-skill") + if err := os.MkdirAll(builtinSkillDir, 0o755); err != nil { + t.Fatalf("MkdirAll(builtin skill) error = %v", err) + } + if err := os.WriteFile( + filepath.Join(builtinSkillDir, "SKILL.md"), + []byte("---\nname: builtin-skill\ndescription: Builtin skill\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile(builtin skill) error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillSupportResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if len(resp.Skills) != 3 { + t.Fatalf("skills count = %d, want 3", len(resp.Skills)) + } + + gotSkills := make(map[string]string, len(resp.Skills)) + for _, skill := range resp.Skills { + gotSkills[skill.Name] = skill.Source + } + if gotSkills["workspace-skill"] != "workspace" { + t.Fatalf("workspace-skill source = %q, want workspace", gotSkills["workspace-skill"]) + } + if gotSkills["global-skill"] != "global" { + t.Fatalf("global-skill source = %q, want global", gotSkills["global-skill"]) + } + if gotSkills["builtin-skill"] != "builtin" { + t.Fatalf("builtin-skill source = %q, want builtin", gotSkills["builtin-skill"]) + } +} + +func TestHandleGetSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + skillDir := filepath.Join(workspace, "skills", "viewer-skill") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte( + "---\nname: viewer-skill\ndescription: Viewable skill\n---\n# Viewer Skill\n\nThis is visible content.\n", + ), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/viewer-skill", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillDetailResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Name != "viewer-skill" || resp.Source != "workspace" || resp.Description != "Viewable skill" { + t.Fatalf("unexpected response: %#v", resp) + } + if resp.Content != "# Viewer Skill\n\nThis is visible content.\n" { + t.Fatalf("content = %q", resp.Content) + } +} + +func TestHandleGetSkillUsesResolvedPath(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + skillDir := filepath.Join(workspace, "skills", "folder-name") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: display-name\ndescription: Mismatched path skill\n---\n# Display Name\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/skills/display-name", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp skillDetailResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + if resp.Name != "display-name" { + t.Fatalf("resp.Name = %q, want display-name", resp.Name) + } + if resp.Content != "# Display Name\n" { + t.Fatalf("content = %q", resp.Content) + } +} + +func TestHandleImportSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + part, err := writer.CreateFormFile("file", "Plain Skill.md") + if err != nil { + t.Fatalf("CreateFormFile() error = %v", err) + } + _, err = io.WriteString(part, "# Plain Skill\n\nUse this skill to test imports.\n") + if err != nil { + t.Fatalf("WriteString() error = %v", err) + } + err = writer.Close() + if err != nil { + t.Fatalf("Close() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/skills/import", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + skillFile := filepath.Join(workspace, "skills", "plain-skill", "SKILL.md") + content, err := os.ReadFile(skillFile) + if err != nil { + t.Fatalf("ReadFile() error = %v", err) + } + expected := "---\nname: plain-skill\ndescription: Plain Skill\n---\n\n# Plain Skill\n\nUse this skill to test imports.\n" + if string(content) != expected { + t.Fatalf("saved skill content mismatch:\n%s", string(content)) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest(http.MethodGet, "/api/skills", nil) + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) + } + var listResp skillSupportResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &listResp); err != nil { + t.Fatalf("Unmarshal list response error = %v", err) + } + found := false + for _, skill := range listResp.Skills { + if skill.Name == "plain-skill" && skill.Source == "workspace" && skill.Description == "Plain Skill" { + found = true + } + } + if !found { + t.Fatalf("plain-skill should be listed after import, got %#v", listResp.Skills) + } +} + +func TestHandleDeleteSkill(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + workspace := filepath.Join(t.TempDir(), "workspace") + cfg.Agents.Defaults.Workspace = workspace + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + skillDir := filepath.Join(workspace, "skills", "delete-me") + if err := os.MkdirAll(skillDir, 0o755); err != nil { + t.Fatalf("MkdirAll() error = %v", err) + } + if err := os.WriteFile( + filepath.Join(skillDir, "SKILL.md"), + []byte("---\nname: delete-me\ndescription: delete me\n---\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodDelete, "/api/skills/delete-me", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + if _, err := os.Stat(skillDir); !os.IsNotExist(err) { + t.Fatalf("skill directory should be removed, stat err=%v", err) + } +} diff --git a/web/backend/api/startup.go b/web/backend/api/startup.go new file mode 100644 index 000000000..1c685bc90 --- /dev/null +++ b/web/backend/api/startup.go @@ -0,0 +1,305 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +const ( + autoStartEntryName = "PicoClawLauncher" + launchAgentLabel = "io.picoclaw.launcher" +) + +type autoStartRequest struct { + Enabled bool `json:"enabled"` +} + +type autoStartResponse struct { + Enabled bool `json:"enabled"` + Supported bool `json:"supported"` + Platform string `json:"platform"` + Message string `json:"message,omitempty"` +} + +var errAutoStartUnsupported = errors.New("autostart is not supported on this platform") + +func (h *Handler) registerStartupRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/system/autostart", h.handleGetAutoStart) + mux.HandleFunc("PUT /api/system/autostart", h.handleSetAutoStart) +} + +func (h *Handler) handleGetAutoStart(w http.ResponseWriter, r *http.Request) { + enabled, supported, message, err := h.getAutoStartStatus() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to read startup setting: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(autoStartResponse{ + Enabled: enabled, + Supported: supported, + Platform: runtime.GOOS, + Message: message, + }) +} + +func (h *Handler) handleSetAutoStart(w http.ResponseWriter, r *http.Request) { + var req autoStartRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if err := h.setAutoStart(req.Enabled); err != nil { + if errors.Is(err, errAutoStartUnsupported) { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + http.Error(w, fmt.Sprintf("Failed to update startup setting: %v", err), http.StatusInternalServerError) + return + } + + enabled, supported, message, err := h.getAutoStartStatus() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to verify startup setting: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(autoStartResponse{ + Enabled: enabled, + Supported: supported, + Platform: runtime.GOOS, + Message: message, + }) +} + +func (h *Handler) resolveLaunchCommand() (string, []string, error) { + exePath, err := os.Executable() + if err != nil { + return "", nil, err + } + + args := []string{"-no-browser"} + if h.configPath != "" { + args = append(args, h.configPath) + } + + return exePath, args, nil +} + +func (h *Handler) getAutoStartStatus() (enabled bool, supported bool, message string, err error) { + switch runtime.GOOS { + case "darwin": + exists, err := fileExists(macLaunchAgentPath()) + return exists, true, "Changes apply on next login.", err + case "linux": + exists, err := fileExists(linuxAutoStartPath()) + return exists, true, "Changes apply on next login.", err + case "windows": + exists, err := windowsRunKeyExists() + return exists, true, "Changes apply on next login.", err + default: + return false, false, "Current platform does not support launch at login.", nil + } +} + +func (h *Handler) setAutoStart(enabled bool) error { + exePath, args, err := h.resolveLaunchCommand() + if err != nil { + return err + } + + switch runtime.GOOS { + case "darwin": + return setDarwinAutoStart(enabled, exePath, args) + case "linux": + return setLinuxAutoStart(enabled, exePath, args) + case "windows": + return setWindowsAutoStart(enabled, exePath, args) + default: + return errAutoStartUnsupported + } +} + +func fileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + return true, nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +func macLaunchAgentPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, "Library", "LaunchAgents", launchAgentLabel+".plist") +} + +func setDarwinAutoStart(enabled bool, exePath string, args []string) error { + plistPath := macLaunchAgentPath() + if enabled { + if err := os.MkdirAll(filepath.Dir(plistPath), 0o755); err != nil { + return err + } + content := buildDarwinPlist(exePath, args) + return os.WriteFile(plistPath, []byte(content), 0o644) + } + + if err := os.Remove(plistPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func xmlEscape(s string) string { + var b bytes.Buffer + for _, r := range s { + switch r { + case '&': + b.WriteString("&") + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + case '"': + b.WriteString(""") + case '\'': + b.WriteString("'") + default: + b.WriteRune(r) + } + } + return b.String() +} + +func buildDarwinPlist(exePath string, args []string) string { + programArgs := make([]string, 0, len(args)+1) + programArgs = append(programArgs, exePath) + programArgs = append(programArgs, args...) + + var b strings.Builder + b.WriteString(`` + "\n") + b.WriteString( + `` + "\n", + ) + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + b.WriteString(` Label` + "\n") + b.WriteString(` ` + launchAgentLabel + `` + "\n") + b.WriteString(` ProgramArguments` + "\n") + b.WriteString(` ` + "\n") + for _, arg := range programArgs { + b.WriteString(` ` + xmlEscape(arg) + `` + "\n") + } + b.WriteString(` ` + "\n") + b.WriteString(` RunAtLoad` + "\n") + b.WriteString(` ` + "\n") + b.WriteString(` ProcessType` + "\n") + b.WriteString(` Background` + "\n") + b.WriteString(`` + "\n") + b.WriteString(`` + "\n") + return b.String() +} + +func linuxAutoStartPath() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "autostart", "picoclaw-web.desktop") +} + +func shellQuote(s string) string { + if s == "" { + return "''" + } + if !strings.ContainsAny(s, " \t\n'\"\\$`") { + return s + } + return "'" + strings.ReplaceAll(s, "'", "'\"'\"'") + "'" +} + +func buildLinuxExecLine(exePath string, args []string) string { + parts := make([]string, 0, len(args)+1) + parts = append(parts, shellQuote(exePath)) + for _, arg := range args { + parts = append(parts, shellQuote(arg)) + } + return strings.Join(parts, " ") +} + +func setLinuxAutoStart(enabled bool, exePath string, args []string) error { + desktopPath := linuxAutoStartPath() + if enabled { + if err := os.MkdirAll(filepath.Dir(desktopPath), 0o755); err != nil { + return err + } + content := strings.Join([]string{ + "[Desktop Entry]", + "Type=Application", + "Version=1.0", + "Name=PicoClaw Web", + "Comment=Start PicoClaw Web on login", + "Exec=" + buildLinuxExecLine(exePath, args), + "Terminal=false", + "X-GNOME-Autostart-enabled=true", + "NoDisplay=true", + "", + }, "\n") + return os.WriteFile(desktopPath, []byte(content), 0o644) + } + + if err := os.Remove(desktopPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func windowsCommandLine(exePath string, args []string) string { + parts := make([]string, 0, len(args)+1) + parts = append(parts, fmt.Sprintf("%q", exePath)) + for _, arg := range args { + parts = append(parts, fmt.Sprintf("%q", arg)) + } + return strings.Join(parts, " ") +} + +func windowsRunKeyExists() (bool, error) { + cmd := exec.Command("reg", "query", `HKCU\Software\Microsoft\Windows\CurrentVersion\Run`, "/v", autoStartEntryName) + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return false, nil + } + return false, err + } + return true, nil +} + +func setWindowsAutoStart(enabled bool, exePath string, args []string) error { + key := `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` + if enabled { + commandLine := windowsCommandLine(exePath, args) + cmd := exec.Command("reg", "add", key, "/v", autoStartEntryName, "/t", "REG_SZ", "/d", commandLine, "/f") + return cmd.Run() + } + + cmd := exec.Command("reg", "delete", key, "/v", autoStartEntryName, "/f") + if err := cmd.Run(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return nil + } + return err + } + return nil +} diff --git a/web/backend/api/startup_test.go b/web/backend/api/startup_test.go new file mode 100644 index 000000000..cfa9b4c53 --- /dev/null +++ b/web/backend/api/startup_test.go @@ -0,0 +1,56 @@ +package api + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/web/backend/launcherconfig" +) + +func TestResolveLaunchCommandUsesConfigFileDefaults(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + h := NewHandler(configPath) + + // Persist non-default launcher options to ensure resolveLaunchCommand does not + // pin them into autostart args. + launcherPath := launcherconfig.PathForAppConfig(configPath) + if err := launcherconfig.Save(launcherPath, launcherconfig.Config{ + Port: 19999, + Public: true, + }); err != nil { + t.Fatalf("launcherconfig.Save() error = %v", err) + } + + exePath, args, err := h.resolveLaunchCommand() + if err != nil { + t.Fatalf("resolveLaunchCommand() error = %v", err) + } + if exePath == "" { + t.Fatal("resolveLaunchCommand() returned empty executable path") + } + if len(args) != 2 { + t.Fatalf("args len = %d, want 2 (got %v)", len(args), args) + } + if args[0] != "-no-browser" { + t.Fatalf("args[0] = %q, want %q", args[0], "-no-browser") + } + if args[1] != configPath { + t.Fatalf("args[1] = %q, want %q", args[1], configPath) + } + for _, arg := range args { + if arg == "-port" || arg == "-public" { + t.Fatalf("autostart args should not pin network flags, got %v", args) + } + } +} + +func TestBuildDarwinPlistIncludesRunAtLoad(t *testing.T) { + plist := buildDarwinPlist("/tmp/picoclaw-web", []string{"-no-browser", "/tmp/config.json"}) + if !strings.Contains(plist, "RunAtLoad") { + t.Fatalf("plist missing RunAtLoad key:\n%s", plist) + } + if !strings.Contains(plist, "") { + t.Fatalf("plist missing RunAtLoad true value:\n%s", plist) + } +} diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go new file mode 100644 index 000000000..373a3be12 --- /dev/null +++ b/web/backend/api/tools.go @@ -0,0 +1,323 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "runtime" + + "github.com/sipeed/picoclaw/pkg/config" +) + +type toolCatalogEntry struct { + Name string + Description string + Category string + ConfigKey string +} + +type toolSupportItem struct { + Name string `json:"name"` + Description string `json:"description"` + Category string `json:"category"` + ConfigKey string `json:"config_key"` + Status string `json:"status"` + ReasonCode string `json:"reason_code,omitempty"` +} + +type toolSupportResponse struct { + Tools []toolSupportItem `json:"tools"` +} + +type toolStateRequest struct { + Enabled bool `json:"enabled"` +} + +var toolCatalog = []toolCatalogEntry{ + { + Name: "read_file", + Description: "Read file content from the workspace or explicitly allowed paths.", + Category: "filesystem", + ConfigKey: "read_file", + }, + { + Name: "write_file", + Description: "Create or overwrite files within the writable workspace scope.", + Category: "filesystem", + ConfigKey: "write_file", + }, + { + Name: "list_dir", + Description: "Inspect directories and enumerate files available to the agent.", + Category: "filesystem", + ConfigKey: "list_dir", + }, + { + Name: "edit_file", + Description: "Apply targeted edits to existing files without rewriting everything.", + Category: "filesystem", + ConfigKey: "edit_file", + }, + { + Name: "append_file", + Description: "Append content to the end of an existing file.", + Category: "filesystem", + ConfigKey: "append_file", + }, + { + Name: "exec", + Description: "Run shell commands inside the configured workspace sandbox.", + Category: "filesystem", + ConfigKey: "exec", + }, + { + Name: "cron", + Description: "Schedule one-time or recurring reminders, jobs, and shell commands.", + Category: "automation", + ConfigKey: "cron", + }, + { + Name: "web_search", + Description: "Search the web using the configured providers.", + Category: "web", + ConfigKey: "web", + }, + { + Name: "web_fetch", + Description: "Fetch and summarize the contents of a webpage.", + Category: "web", + ConfigKey: "web_fetch", + }, + { + Name: "message", + Description: "Send a follow-up message back to the active user or chat.", + Category: "communication", + ConfigKey: "message", + }, + { + Name: "send_file", + Description: "Send an outbound file or media attachment to the active chat.", + Category: "communication", + ConfigKey: "send_file", + }, + { + Name: "find_skills", + Description: "Search external skill registries for installable skills.", + Category: "skills", + ConfigKey: "find_skills", + }, + { + Name: "install_skill", + Description: "Install a skill into the current workspace from a registry.", + Category: "skills", + ConfigKey: "install_skill", + }, + { + Name: "spawn", + Description: "Launch a background subagent for long-running or delegated work.", + Category: "agents", + ConfigKey: "spawn", + }, + { + Name: "i2c", + Description: "Interact with I2C hardware devices exposed on the host.", + Category: "hardware", + ConfigKey: "i2c", + }, + { + Name: "spi", + Description: "Interact with SPI hardware devices exposed on the host.", + Category: "hardware", + ConfigKey: "spi", + }, + { + Name: "tool_search_tool_regex", + Description: "Discover hidden MCP tools by regex search when tool discovery is enabled.", + Category: "discovery", + ConfigKey: "mcp.discovery.use_regex", + }, + { + Name: "tool_search_tool_bm25", + Description: "Discover hidden MCP tools by semantic ranking when tool discovery is enabled.", + Category: "discovery", + ConfigKey: "mcp.discovery.use_bm25", + }, +} + +func (h *Handler) registerToolRoutes(mux *http.ServeMux) { + mux.HandleFunc("GET /api/tools", h.handleListTools) + mux.HandleFunc("PUT /api/tools/{name}/state", h.handleUpdateToolState) +} + +func (h *Handler) handleListTools(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(toolSupportResponse{ + Tools: buildToolSupport(cfg), + }) +} + +func (h *Handler) handleUpdateToolState(w http.ResponseWriter, r *http.Request) { + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + var req toolStateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if err := applyToolState(cfg, r.PathValue("name"), req.Enabled); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := config.SaveConfig(h.configPath, cfg); err != nil { + http.Error(w, fmt.Sprintf("Failed to save config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func buildToolSupport(cfg *config.Config) []toolSupportItem { + items := make([]toolSupportItem, 0, len(toolCatalog)) + for _, entry := range toolCatalog { + status := "disabled" + reasonCode := "" + + switch entry.Name { + case "find_skills", "install_skill": + if cfg.Tools.IsToolEnabled(entry.ConfigKey) { + if cfg.Tools.IsToolEnabled("skills") { + status = "enabled" + } else { + status = "blocked" + reasonCode = "requires_skills" + } + } + case "spawn": + if cfg.Tools.IsToolEnabled(entry.ConfigKey) { + if cfg.Tools.IsToolEnabled("subagent") { + status = "enabled" + } else { + status = "blocked" + reasonCode = "requires_subagent" + } + } + case "tool_search_tool_regex": + status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseRegex) + case "tool_search_tool_bm25": + status, reasonCode = resolveDiscoveryToolSupport(cfg, cfg.Tools.MCP.Discovery.UseBM25) + case "i2c", "spi": + status, reasonCode = resolveHardwareToolSupport(cfg.Tools.IsToolEnabled(entry.ConfigKey)) + default: + if cfg.Tools.IsToolEnabled(entry.ConfigKey) { + status = "enabled" + } + } + + items = append(items, toolSupportItem{ + Name: entry.Name, + Description: entry.Description, + Category: entry.Category, + ConfigKey: entry.ConfigKey, + Status: status, + ReasonCode: reasonCode, + }) + } + return items +} + +func resolveHardwareToolSupport(enabled bool) (string, string) { + if !enabled { + return "disabled", "" + } + if runtime.GOOS != "linux" { + return "blocked", "requires_linux" + } + return "enabled", "" +} + +func resolveDiscoveryToolSupport(cfg *config.Config, methodEnabled bool) (string, string) { + if !cfg.Tools.IsToolEnabled("mcp") { + return "disabled", "" + } + if !cfg.Tools.MCP.Discovery.Enabled { + return "blocked", "requires_mcp_discovery" + } + if !methodEnabled { + return "disabled", "" + } + return "enabled", "" +} + +func applyToolState(cfg *config.Config, toolName string, enabled bool) error { + switch toolName { + case "read_file": + cfg.Tools.ReadFile.Enabled = enabled + case "write_file": + cfg.Tools.WriteFile.Enabled = enabled + case "list_dir": + cfg.Tools.ListDir.Enabled = enabled + case "edit_file": + cfg.Tools.EditFile.Enabled = enabled + case "append_file": + cfg.Tools.AppendFile.Enabled = enabled + case "exec": + cfg.Tools.Exec.Enabled = enabled + case "cron": + cfg.Tools.Cron.Enabled = enabled + case "web_search": + cfg.Tools.Web.Enabled = enabled + case "web_fetch": + cfg.Tools.WebFetch.Enabled = enabled + case "message": + cfg.Tools.Message.Enabled = enabled + case "send_file": + cfg.Tools.SendFile.Enabled = enabled + case "find_skills": + cfg.Tools.FindSkills.Enabled = enabled + if enabled { + cfg.Tools.Skills.Enabled = true + } + case "install_skill": + cfg.Tools.InstallSkill.Enabled = enabled + if enabled { + cfg.Tools.Skills.Enabled = true + } + case "spawn": + cfg.Tools.Spawn.Enabled = enabled + if enabled { + cfg.Tools.Subagent.Enabled = true + } + case "i2c": + cfg.Tools.I2C.Enabled = enabled + case "spi": + cfg.Tools.SPI.Enabled = enabled + case "tool_search_tool_regex": + cfg.Tools.MCP.Discovery.UseRegex = enabled + if enabled { + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Discovery.Enabled = true + } + case "tool_search_tool_bm25": + cfg.Tools.MCP.Discovery.UseBM25 = enabled + if enabled { + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Discovery.Enabled = true + } + default: + return fmt.Errorf("tool %q cannot be updated", toolName) + } + return nil +} diff --git a/web/backend/api/tools_test.go b/web/backend/api/tools_test.go new file mode 100644 index 000000000..646cefbe2 --- /dev/null +++ b/web/backend/api/tools_test.go @@ -0,0 +1,198 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "runtime" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestHandleListTools(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.ReadFile.Enabled = true + cfg.Tools.WriteFile.Enabled = false + cfg.Tools.Cron.Enabled = true + cfg.Tools.FindSkills.Enabled = true + cfg.Tools.Skills.Enabled = true + cfg.Tools.Spawn.Enabled = true + cfg.Tools.Subagent.Enabled = false + cfg.Tools.MCP.Enabled = true + cfg.Tools.MCP.Discovery.Enabled = true + cfg.Tools.MCP.Discovery.UseRegex = true + cfg.Tools.MCP.Discovery.UseBM25 = false + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp toolSupportResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + gotTools := make(map[string]toolSupportItem, len(resp.Tools)) + for _, tool := range resp.Tools { + gotTools[tool.Name] = tool + } + if gotTools["read_file"].Status != "enabled" { + t.Fatalf("read_file status = %q, want enabled", gotTools["read_file"].Status) + } + if gotTools["write_file"].Status != "disabled" { + t.Fatalf("write_file status = %q, want disabled", gotTools["write_file"].Status) + } + if gotTools["cron"].Status != "enabled" { + t.Fatalf("cron status = %q, want enabled", gotTools["cron"].Status) + } + if gotTools["spawn"].Status != "blocked" || gotTools["spawn"].ReasonCode != "requires_subagent" { + t.Fatalf("spawn = %#v, want blocked/requires_subagent", gotTools["spawn"]) + } + if gotTools["find_skills"].Status != "enabled" { + t.Fatalf("find_skills status = %q, want enabled", gotTools["find_skills"].Status) + } + if gotTools["tool_search_tool_regex"].Status != "enabled" { + t.Fatalf("tool_search_tool_regex status = %q, want enabled", gotTools["tool_search_tool_regex"].Status) + } + if gotTools["tool_search_tool_regex"].ConfigKey != "mcp.discovery.use_regex" { + t.Fatalf( + "tool_search_tool_regex config_key = %q, want mcp.discovery.use_regex", + gotTools["tool_search_tool_regex"].ConfigKey, + ) + } + if gotTools["tool_search_tool_bm25"].Status != "disabled" { + t.Fatalf("tool_search_tool_bm25 status = %q, want disabled", gotTools["tool_search_tool_bm25"].Status) + } + if gotTools["tool_search_tool_bm25"].ConfigKey != "mcp.discovery.use_bm25" { + t.Fatalf( + "tool_search_tool_bm25 config_key = %q, want mcp.discovery.use_bm25", + gotTools["tool_search_tool_bm25"].ConfigKey, + ) + } + if runtime.GOOS == "linux" { + if gotTools["i2c"].Status != "disabled" { + t.Fatalf("i2c status = %q, want disabled on linux when config is off", gotTools["i2c"].Status) + } + } else { + cfg.Tools.I2C.Enabled = true + cfg.Tools.SPI.Enabled = true + if err := config.SaveConfig(configPath, cfg); err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + rec = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodGet, "/api/tools", nil) + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("Unmarshal() error = %v", err) + } + gotTools = make(map[string]toolSupportItem, len(resp.Tools)) + for _, tool := range resp.Tools { + gotTools[tool.Name] = tool + } + + if gotTools["i2c"].Status != "blocked" || gotTools["i2c"].ReasonCode != "requires_linux" { + t.Fatalf("i2c = %#v, want blocked/requires_linux", gotTools["i2c"]) + } + if gotTools["spi"].Status != "blocked" || gotTools["spi"].ReasonCode != "requires_linux" { + t.Fatalf("spi = %#v, want blocked/requires_linux", gotTools["spi"]) + } + } +} + +func TestHandleUpdateToolState(t *testing.T) { + configPath, cleanup := setupOAuthTestEnv(t) + defer cleanup() + + cfg, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + cfg.Tools.Spawn.Enabled = false + cfg.Tools.Subagent.Enabled = false + cfg.Tools.Cron.Enabled = false + cfg.Tools.MCP.Enabled = false + cfg.Tools.MCP.Discovery.Enabled = false + cfg.Tools.MCP.Discovery.UseRegex = false + err = config.SaveConfig(configPath, cfg) + if err != nil { + t.Fatalf("SaveConfig() error = %v", err) + } + + h := NewHandler(configPath) + mux := http.NewServeMux() + h.RegisterRoutes(mux) + + rec := httptest.NewRecorder() + req := httptest.NewRequest( + http.MethodPut, + "/api/tools/spawn/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("spawn status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String()) + } + + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest( + http.MethodPut, + "/api/tools/tool_search_tool_regex/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req2.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK { + t.Fatalf("regex status = %d, want %d, body=%s", rec2.Code, http.StatusOK, rec2.Body.String()) + } + + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest( + http.MethodPut, + "/api/tools/cron/state", + bytes.NewBufferString(`{"enabled":true}`), + ) + req3.Header.Set("Content-Type", "application/json") + mux.ServeHTTP(rec3, req3) + if rec3.Code != http.StatusOK { + t.Fatalf("cron status = %d, want %d, body=%s", rec3.Code, http.StatusOK, rec3.Body.String()) + } + + updated, err := config.LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig(updated) error = %v", err) + } + if !updated.Tools.Spawn.Enabled || !updated.Tools.Subagent.Enabled { + t.Fatalf("spawn/subagent should both be enabled: %#v", updated.Tools) + } + if !updated.Tools.MCP.Enabled || !updated.Tools.MCP.Discovery.Enabled || !updated.Tools.MCP.Discovery.UseRegex { + t.Fatalf("mcp regex discovery should be enabled: %#v", updated.Tools.MCP) + } + if !updated.Tools.Cron.Enabled { + t.Fatalf("cron should be enabled: %#v", updated.Tools.Cron) + } +} diff --git a/web/backend/dist/.gitkeep b/web/backend/dist/.gitkeep new file mode 100644 index 000000000..4b533f03a --- /dev/null +++ b/web/backend/dist/.gitkeep @@ -0,0 +1 @@ +# Keep the embedded web backend dist directory in version control. diff --git a/web/backend/embed.go b/web/backend/embed.go new file mode 100644 index 000000000..2b28f84b9 --- /dev/null +++ b/web/backend/embed.go @@ -0,0 +1,77 @@ +package main + +import ( + "embed" + "io/fs" + "log" + "mime" + "net/http" + "path" + "strings" +) + +//go:embed all:dist +var frontendFS embed.FS + +// registerEmbedRoutes sets up the HTTP handler to serve the embedded frontend files +func registerEmbedRoutes(mux *http.ServeMux) { + // Register correct MIME type for SVG files + // Go's built-in mime.TypeByExtension returns "image/svg" which is incorrect + // The correct MIME type per RFC 6838 is "image/svg+xml" + if err := mime.AddExtensionType(".svg", "image/svg+xml"); err != nil { + log.Printf("Warning: failed to register SVG MIME type: %v", err) + } + + // Attempt to get the subdirectory 'dist' where Vite usually builds + subFS, err := fs.Sub(frontendFS, "dist") + if err != nil { + // Log a warning if dist doesn't exist yet (e.g., during development before a frontend build) + log.Printf( + "Warning: no 'dist' folder found in embedded frontend. " + + "Ensure you run `pnpm build:backend` in the frontend directory " + + "before building the Go backend.", + ) + return + } + + fileServer := http.FileServer(http.FS(subFS)) + + // Serve static assets and fallback to index.html for SPA routes. + mux.Handle( + "/", + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.NotFound(w, r) + return + } + + // Keep unknown API paths as 404 instead of falling back to SPA entry. + if r.URL.Path == "/api" || strings.HasPrefix(r.URL.Path, "/api/") { + http.NotFound(w, r) + return + } + + cleanPath := path.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if cleanPath == "." { + cleanPath = "" + } + + // Existing static files/directories should be served directly. + if cleanPath != "" { + if _, statErr := fs.Stat(subFS, cleanPath); statErr == nil { + fileServer.ServeHTTP(w, r) + return + } + // Missing asset-like paths should remain 404. + if strings.Contains(path.Base(cleanPath), ".") { + fileServer.ServeHTTP(w, r) + return + } + } + + indexReq := r.Clone(r.Context()) + indexReq.URL.Path = "/" + fileServer.ServeHTTP(w, indexReq) + }), + ) +} diff --git a/web/backend/embed_test.go b/web/backend/embed_test.go new file mode 100644 index 000000000..c0365488e --- /dev/null +++ b/web/backend/embed_test.go @@ -0,0 +1,33 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestUnknownAPIPathStays404(t *testing.T) { + mux := http.NewServeMux() + registerEmbedRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/api/not-found", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) + } +} + +func TestMissingAssetStays404(t *testing.T) { + mux := http.NewServeMux() + registerEmbedRoutes(mux) + + req := httptest.NewRequest(http.MethodGet, "/assets/not-found.js", nil) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) + } +} diff --git a/web/backend/icon.ico b/web/backend/icon.ico new file mode 100644 index 000000000..4f6539414 Binary files /dev/null and b/web/backend/icon.ico differ diff --git a/web/backend/launcherconfig/config.go b/web/backend/launcherconfig/config.go new file mode 100644 index 000000000..4dca45b0e --- /dev/null +++ b/web/backend/launcherconfig/config.go @@ -0,0 +1,113 @@ +package launcherconfig + +import ( + "encoding/json" + "fmt" + "net" + "os" + "path/filepath" + "strings" +) + +const ( + // FileName is the launcher-specific settings file name. + FileName = "launcher-config.json" + // DefaultPort is the default port for the web launcher. + DefaultPort = 18800 +) + +// Config stores launch parameters for the web backend service. +type Config struct { + Port int `json:"port"` + Public bool `json:"public"` + AllowedCIDRs []string `json:"allowed_cidrs,omitempty"` +} + +// Default returns default launcher settings. +func Default() Config { + return Config{Port: DefaultPort, Public: false} +} + +// Validate checks if launcher settings are valid. +func Validate(cfg Config) error { + if cfg.Port < 1 || cfg.Port > 65535 { + return fmt.Errorf("port %d is out of range (1-65535)", cfg.Port) + } + for _, cidr := range cfg.AllowedCIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return fmt.Errorf("invalid CIDR %q", cidr) + } + } + return nil +} + +// NormalizeCIDRs trims entries, removes empty values, and deduplicates CIDRs. +func NormalizeCIDRs(cidrs []string) []string { + if len(cidrs) == 0 { + return nil + } + out := make([]string, 0, len(cidrs)) + seen := make(map[string]struct{}, len(cidrs)) + for _, raw := range cidrs { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + out = append(out, trimmed) + } + if len(out) == 0 { + return nil + } + return out +} + +// PathForAppConfig returns launcher-config path near the app config file. +func PathForAppConfig(appConfigPath string) string { + dir := filepath.Dir(appConfigPath) + if dir == "" || dir == "." { + dir = "." + } + return filepath.Join(dir, FileName) +} + +// Load reads launcher settings; fallback is returned when file does not exist. +func Load(path string, fallback Config) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return fallback, nil + } + return Config{}, err + } + + cfg := fallback + if err := json.Unmarshal(data, &cfg); err != nil { + return Config{}, err + } + cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) + if err := Validate(cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +// Save writes launcher settings to disk. +func Save(path string, cfg Config) error { + cfg.AllowedCIDRs = NormalizeCIDRs(cfg.AllowedCIDRs) + if err := Validate(cfg); err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o600) +} diff --git a/web/backend/launcherconfig/config_test.go b/web/backend/launcherconfig/config_test.go new file mode 100644 index 000000000..c63bee09a --- /dev/null +++ b/web/backend/launcherconfig/config_test.go @@ -0,0 +1,89 @@ +package launcherconfig + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadReturnsFallbackWhenMissing(t *testing.T) { + path := filepath.Join(t.TempDir(), "launcher-config.json") + fallback := Config{Port: 19999, Public: true} + + got, err := Load(path, fallback) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.Port != fallback.Port || got.Public != fallback.Public { + t.Fatalf("Load() = %+v, want %+v", got, fallback) + } +} + +func TestSaveAndLoadRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "launcher-config.json") + want := Config{ + Port: 18080, + Public: true, + AllowedCIDRs: []string{"192.168.1.0/24", "10.0.0.0/8"}, + } + + if err := Save(path, want); err != nil { + t.Fatalf("Save() error = %v", err) + } + got, err := Load(path, Default()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if got.Port != want.Port || got.Public != want.Public { + t.Fatalf("Load() = %+v, want %+v", got, want) + } + if len(got.AllowedCIDRs) != len(want.AllowedCIDRs) { + t.Fatalf("allowed_cidrs len = %d, want %d", len(got.AllowedCIDRs), len(want.AllowedCIDRs)) + } + for i := range want.AllowedCIDRs { + if got.AllowedCIDRs[i] != want.AllowedCIDRs[i] { + t.Fatalf("allowed_cidrs[%d] = %q, want %q", i, got.AllowedCIDRs[i], want.AllowedCIDRs[i]) + } + } + + stat, err := os.Stat(path) + if err != nil { + t.Fatalf("Stat() error = %v", err) + } + if perm := stat.Mode().Perm(); perm != 0o600 { + t.Fatalf("file perm = %o, want 600", perm) + } +} + +func TestValidateRejectsInvalidPort(t *testing.T) { + if err := Validate(Config{Port: 0, Public: false}); err == nil { + t.Fatal("Validate() expected error for port 0") + } + if err := Validate(Config{Port: 65536, Public: false}); err == nil { + t.Fatal("Validate() expected error for port 65536") + } +} + +func TestValidateRejectsInvalidCIDR(t *testing.T) { + err := Validate(Config{ + Port: 18800, + AllowedCIDRs: []string{"192.168.1.0/24", "not-a-cidr"}, + }) + if err == nil { + t.Fatal("Validate() expected error for invalid CIDR") + } +} + +func TestNormalizeCIDRs(t *testing.T) { + got := NormalizeCIDRs([]string{" 192.168.1.0/24 ", "", "10.0.0.0/8", "192.168.1.0/24"}) + want := []string{"192.168.1.0/24", "10.0.0.0/8"} + if len(got) != len(want) { + t.Fatalf("len(got) = %d, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/web/backend/main.go b/web/backend/main.go new file mode 100644 index 000000000..650540ea8 --- /dev/null +++ b/web/backend/main.go @@ -0,0 +1,169 @@ +// PicoClaw Web Console - Web-based chat and management interface +// +// Provides a web UI for chatting with PicoClaw via the Pico Channel WebSocket, +// with configuration management and gateway process control. +// +// Usage: +// +// go build -o picoclaw-web ./web/backend/ +// ./picoclaw-web [config.json] +// ./picoclaw-web -public config.json + +package main + +import ( + "errors" + "flag" + "fmt" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "time" + + "github.com/sipeed/picoclaw/web/backend/api" + "github.com/sipeed/picoclaw/web/backend/launcherconfig" + "github.com/sipeed/picoclaw/web/backend/middleware" + "github.com/sipeed/picoclaw/web/backend/utils" +) + +func main() { + port := flag.String("port", "18800", "Port to listen on") + public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only") + noBrowser := flag.Bool("no-browser", false, "Do not auto-open browser on startup") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "PicoClaw Launcher - A web-based configuration editor\n\n") + fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Arguments:\n") + fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n") + fmt.Fprintf(os.Stderr, "Options:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nExamples:\n") + fmt.Fprintf(os.Stderr, " %s Use default config path\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s ./config.json Specify a config file\n", os.Args[0]) + fmt.Fprintf( + os.Stderr, + " %s -public ./config.json Allow access from other devices on the network\n", + os.Args[0], + ) + } + flag.Parse() + + // Resolve config path + configPath := utils.GetDefaultConfigPath() + if flag.NArg() > 0 { + configPath = flag.Arg(0) + } + + absPath, err := filepath.Abs(configPath) + if err != nil { + log.Fatalf("Failed to resolve config path: %v", err) + } + err = utils.EnsureOnboarded(absPath) + if err != nil { + log.Printf("Warning: Failed to initialize PicoClaw config automatically: %v", err) + } + + var explicitPort bool + var explicitPublic bool + flag.Visit(func(f *flag.Flag) { + switch f.Name { + case "port": + explicitPort = true + case "public": + explicitPublic = true + } + }) + + launcherPath := launcherconfig.PathForAppConfig(absPath) + launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default()) + if err != nil { + log.Printf("Warning: Failed to load %s: %v", launcherPath, err) + launcherCfg = launcherconfig.Default() + } + + effectivePort := *port + effectivePublic := *public + if !explicitPort { + effectivePort = strconv.Itoa(launcherCfg.Port) + } + if !explicitPublic { + effectivePublic = launcherCfg.Public + } + + portNum, err := strconv.Atoi(effectivePort) + if err != nil || portNum < 1 || portNum > 65535 { + if err == nil { + err = errors.New("must be in range 1-65535") + } + log.Fatalf("Invalid port %q: %v", effectivePort, err) + } + + // Determine listen address + var addr string + if effectivePublic { + addr = "0.0.0.0:" + effectivePort + } else { + addr = "127.0.0.1:" + effectivePort + } + + // Initialize Server components + mux := http.NewServeMux() + + // API Routes (e.g. /api/status) + apiHandler := api.NewHandler(absPath) + apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs) + apiHandler.RegisterRoutes(mux) + + // Frontend Embedded Assets + registerEmbedRoutes(mux) + + accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux) + if err != nil { + log.Fatalf("Invalid allowed CIDR configuration: %v", err) + } + + // Apply middleware stack + handler := middleware.Recoverer( + middleware.Logger( + middleware.JSONContentType(accessControlledMux), + ), + ) + + // Print startup banner + fmt.Print(utils.Banner) + fmt.Println() + fmt.Println(" Open the following URL in your browser:") + fmt.Println() + fmt.Printf(" >> http://localhost:%s <<\n", effectivePort) + if effectivePublic { + if ip := utils.GetLocalIP(); ip != "" { + fmt.Printf(" >> http://%s:%s <<\n", ip, effectivePort) + } + } + fmt.Println() + + // Auto-open browser + if !*noBrowser { + go func() { + time.Sleep(500 * time.Millisecond) + url := "http://localhost:" + effectivePort + if err := utils.OpenBrowser(url); err != nil { + log.Printf("Warning: Failed to auto-open browser: %v", err) + } + }() + } + + // Auto-start gateway after backend starts listening. + go func() { + time.Sleep(1 * time.Second) + apiHandler.TryAutoStartGateway() + }() + + // Start the Server + if err := http.ListenAndServe(addr, handler); err != nil { + log.Fatalf("Server failed to start: %v", err) + } +} diff --git a/web/backend/middleware/access_control.go b/web/backend/middleware/access_control.go new file mode 100644 index 000000000..159d60c3e --- /dev/null +++ b/web/backend/middleware/access_control.go @@ -0,0 +1,64 @@ +package middleware + +import ( + "fmt" + "net" + "net/http" + "strings" +) + +// IPAllowlist restricts access to requests from configured CIDR ranges. +// Loopback addresses are always allowed for local administration. +// Empty CIDR list means no restriction. +func IPAllowlist(allowedCIDRs []string, next http.Handler) (http.Handler, error) { + if len(allowedCIDRs) == 0 { + return next, nil + } + + nets := make([]*net.IPNet, 0, len(allowedCIDRs)) + for _, cidr := range allowedCIDRs { + _, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + return nil, fmt.Errorf("invalid CIDR %q: %w", cidr, err) + } + nets = append(nets, ipNet) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := clientIPFromRemoteAddr(r.RemoteAddr) + if ip == nil { + rejectByPolicy(w, r) + return + } + if ip.IsLoopback() { + next.ServeHTTP(w, r) + return + } + for _, ipNet := range nets { + if ipNet.Contains(ip) { + next.ServeHTTP(w, r) + return + } + } + + rejectByPolicy(w, r) + }), nil +} + +func clientIPFromRemoteAddr(remoteAddr string) net.IP { + host := remoteAddr + if h, _, err := net.SplitHostPort(remoteAddr); err == nil { + host = h + } + return net.ParseIP(host) +} + +func rejectByPolicy(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"error":"access denied by network policy"}`)) + return + } + http.Error(w, "Forbidden", http.StatusForbidden) +} diff --git a/web/backend/middleware/access_control_test.go b/web/backend/middleware/access_control_test.go new file mode 100644 index 000000000..259fd4a4c --- /dev/null +++ b/web/backend/middleware/access_control_test.go @@ -0,0 +1,86 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestIPAllowlist_EmptyCIDRsAllowsAll(t *testing.T) { + h, err := IPAllowlist(nil, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "203.0.113.5:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestIPAllowlist_RejectsOutsideCIDR(t *testing.T) { + h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/config", nil) + req.RemoteAddr = "10.0.0.8:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } +} + +func TestIPAllowlist_AllowsInsideCIDR(t *testing.T) { + h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "192.168.1.88:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestIPAllowlist_AlwaysAllowsLoopback(t *testing.T) { + h, err := IPAllowlist([]string{"192.168.1.0/24"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + if err != nil { + t.Fatalf("IPAllowlist() error = %v", err) + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "127.0.0.1:1234" + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestIPAllowlist_InvalidCIDR(t *testing.T) { + _, err := IPAllowlist([]string{"bad-cidr"}, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + if err == nil { + t.Fatal("IPAllowlist() expected error for invalid CIDR") + } +} diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go new file mode 100644 index 000000000..de9e6d870 --- /dev/null +++ b/web/backend/middleware/middleware.go @@ -0,0 +1,70 @@ +package middleware + +import ( + "log" + "net/http" + "runtime/debug" + "strings" + "time" +) + +// JSONContentType sets the Content-Type header to application/json for +// API requests handled by the wrapped handler. +// SSE endpoints (text/event-stream) are excluded. +func JSONContentType(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/") && !strings.HasSuffix(r.URL.Path, "/events") { + w.Header().Set("Content-Type", "application/json") + } + next.ServeHTTP(w, r) + }) +} + +// responseRecorder wraps http.ResponseWriter to capture the status code. +type responseRecorder struct { + http.ResponseWriter + statusCode int +} + +func (rr *responseRecorder) WriteHeader(code int) { + rr.statusCode = code + rr.ResponseWriter.WriteHeader(code) +} + +// Flush delegates to the underlying ResponseWriter if it implements http.Flusher. +// This is required for SSE (Server-Sent Events) to work through the middleware. +func (rr *responseRecorder) Flush() { + if f, ok := rr.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap returns the underlying ResponseWriter so that http.ResponseController +// and interface checks (like http.Flusher) can see through the wrapper. +func (rr *responseRecorder) Unwrap() http.ResponseWriter { + return rr.ResponseWriter +} + +// Logger logs each HTTP request with method, path, status code, and duration. +func Logger(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &responseRecorder{ResponseWriter: w, statusCode: http.StatusOK} + next.ServeHTTP(rec, r) + log.Printf("%s %s %d %s", r.Method, r.URL.Path, rec.statusCode, time.Since(start)) + }) +} + +// Recoverer recovers from panics in downstream handlers and returns a 500 +// Internal Server Error response. +func Recoverer(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if err := recover(); err != nil { + log.Printf("panic recovered: %v\n%s", err, debug.Stack()) + http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/web/backend/model/status.go b/web/backend/model/status.go new file mode 100644 index 000000000..325981502 --- /dev/null +++ b/web/backend/model/status.go @@ -0,0 +1,8 @@ +package model + +// StatusResponse represents the response payload for the GET /api/status endpoint. +type StatusResponse struct { + Status string `json:"status"` + Version string `json:"version"` + Uptime string `json:"uptime"` +} diff --git a/web/backend/utils/banner.go b/web/backend/utils/banner.go new file mode 100644 index 000000000..a64ea6390 --- /dev/null +++ b/web/backend/utils/banner.go @@ -0,0 +1,15 @@ +package utils + +const ( + colorBlue = "\x1b[38;2;62;93;185m" + colorRed = "\x1b[38;2;213;70;70m" + colorReset = "\x1b[0m" + Banner = "\r\n" + + colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" + + colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" + + colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" + + colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" + + colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" + + colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n" + + colorReset +) diff --git a/web/backend/utils/onboard.go b/web/backend/utils/onboard.go new file mode 100644 index 000000000..fbe34f220 --- /dev/null +++ b/web/backend/utils/onboard.go @@ -0,0 +1,42 @@ +package utils + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +var execCommand = exec.Command + +func EnsureOnboarded(configPath string) error { + _, err := os.Stat(configPath) + if err == nil { + return nil + } + if !os.IsNotExist(err) { + return fmt.Errorf("stat config: %w", err) + } + + cmd := execCommand(FindPicoclawBinary(), "onboard") + cmd.Env = append(os.Environ(), "PICOCLAW_CONFIG="+configPath) + cmd.Stdin = strings.NewReader("n\n") + + output, err := cmd.CombinedOutput() + if err != nil { + trimmed := strings.TrimSpace(string(output)) + if trimmed == "" { + return fmt.Errorf("run onboard: %w", err) + } + return fmt.Errorf("run onboard: %w: %s", err, trimmed) + } + + if _, err := os.Stat(configPath); err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("onboard completed but did not create config %s", configPath) + } + return fmt.Errorf("verify config after onboard: %w", err) + } + + return nil +} diff --git a/web/backend/utils/onboard_test.go b/web/backend/utils/onboard_test.go new file mode 100644 index 000000000..06f967e76 --- /dev/null +++ b/web/backend/utils/onboard_test.go @@ -0,0 +1,101 @@ +package utils + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestEnsureOnboardedSkipsWhenConfigExists(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(configPath, []byte(`{}`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + called := false + execCommand = func(name string, args ...string) *exec.Cmd { + called = true + return exec.Command("sh", "-c", "exit 1") + } + + if err := EnsureOnboarded(configPath); err != nil { + t.Fatalf("EnsureOnboarded() error = %v", err) + } + if called { + t.Fatal("expected onboard command not to run when config already exists") + } +} + +func TestEnsureOnboardedRunsOnboardWhenConfigMissing(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + t.Setenv("EXPECTED_CONFIG_PATH", configPath) + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + var gotName string + var gotArgs []string + execCommand = func(name string, args ...string) *exec.Cmd { + gotName = name + gotArgs = append([]string(nil), args...) + return exec.Command( + "sh", + "-c", + `test "$PICOCLAW_CONFIG" = "$EXPECTED_CONFIG_PATH" && +mkdir -p "$(dirname "$PICOCLAW_CONFIG")" && +printf '{}' > "$PICOCLAW_CONFIG"`, + ) + } + + if err := EnsureOnboarded(configPath); err != nil { + t.Fatalf("EnsureOnboarded() error = %v", err) + } + if gotName == "" { + t.Fatal("expected onboard command to run") + } + if len(gotArgs) != 1 || gotArgs[0] != "onboard" { + t.Fatalf("command args = %#v, want []string{\"onboard\"}", gotArgs) + } + if _, err := os.Stat(configPath); err != nil { + t.Fatalf("expected config to be created: %v", err) + } +} + +func TestEnsureOnboardedFailsWhenOnboardDoesNotCreateConfig(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + execCommand = func(name string, args ...string) *exec.Cmd { + return exec.Command("sh", "-c", "exit 0") + } + + if err := EnsureOnboarded(configPath); err == nil { + t.Fatal("EnsureOnboarded() error = nil, want failure when onboard does not create config") + } +} + +func TestEnsureOnboardedIncludesOnboardOutputOnFailure(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + + origExecCommand := execCommand + defer func() { execCommand = origExecCommand }() + + execCommand = func(name string, args ...string) *exec.Cmd { + return exec.Command("sh", "-c", "echo onboarding failed >&2; exit 2") + } + + err := EnsureOnboarded(configPath) + if err == nil { + t.Fatal("EnsureOnboarded() error = nil, want failure") + } + if !strings.Contains(err.Error(), "onboarding failed") { + t.Fatalf("error = %q, want onboard output included", err) + } +} diff --git a/web/backend/utils/runtime.go b/web/backend/utils/runtime.go new file mode 100644 index 000000000..4e6c32c56 --- /dev/null +++ b/web/backend/utils/runtime.go @@ -0,0 +1,80 @@ +package utils + +import ( + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "runtime" +) + +// GetDefaultConfigPath returns the default path to the picoclaw config file. +func GetDefaultConfigPath() string { + if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" { + return configPath + } + if picoclawHome := os.Getenv("PICOCLAW_HOME"); picoclawHome != "" { + return filepath.Join(picoclawHome, "config.json") + } + home, err := os.UserHomeDir() + if err != nil { + return "config.json" + } + return filepath.Join(home, ".picoclaw", "config.json") +} + +// FindPicoclawBinary locates the picoclaw executable. +// Search order: +// 1. PICOCLAW_BINARY environment variable (explicit override) +// 2. Same directory as the current executable +// 3. Falls back to "picoclaw" and relies on $PATH +func FindPicoclawBinary() string { + binaryName := "picoclaw" + if runtime.GOOS == "windows" { + binaryName = "picoclaw.exe" + } + + if p := os.Getenv("PICOCLAW_BINARY"); p != "" { + if info, _ := os.Stat(p); info != nil && !info.IsDir() { + return p + } + } + + if exe, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exe), binaryName) + if info, err := os.Stat(candidate); err == nil && !info.IsDir() { + return candidate + } + } + + return "picoclaw" +} + +// GetLocalIP returns the local IP address of the machine. +func GetLocalIP() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, a := range addrs { + if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil { + return ipnet.IP.String() + } + } + return "" +} + +// OpenBrowser automatically opens the given URL in the default browser. +func OpenBrowser(url string) error { + switch runtime.GOOS { + case "linux": + return exec.Command("xdg-open", url).Start() + case "windows": + return exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start() + case "darwin": + return exec.Command("open", url).Start() + default: + return fmt.Errorf("unsupported platform") + } +} diff --git a/web/backend/winres/winres.json b/web/backend/winres/winres.json new file mode 100644 index 000000000..01ea7364c --- /dev/null +++ b/web/backend/winres/winres.json @@ -0,0 +1,22 @@ +{ + "RT_GROUP_ICON": { + "APP": { + "0000": "../icon.ico" + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "PicoClaw Launcher", + "version": "0.0.0.0" + }, + "description": "PicoClaw Launcher - Web-based configuration editor", + "minimum-os": "win7", + "execution-level": "asInvoker", + "dpi-awareness": "system", + "use-common-controls-v6": true + } + } + } +} diff --git a/web/frontend/.editorconfig b/web/frontend/.editorconfig new file mode 100644 index 000000000..a8c0f1ecf --- /dev/null +++ b/web/frontend/.editorconfig @@ -0,0 +1,7 @@ +root = true + +[*] +charset = utf-8 +indent_style = space +indent_size = 2 +end_of_line = lf \ No newline at end of file diff --git a/web/frontend/.gitignore b/web/frontend/.gitignore new file mode 100644 index 000000000..72e68ffba --- /dev/null +++ b/web/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +.tanstack diff --git a/web/frontend/.prettierignore b/web/frontend/.prettierignore new file mode 100644 index 000000000..7040bf59e --- /dev/null +++ b/web/frontend/.prettierignore @@ -0,0 +1,5 @@ +package-lock.json +pnpm-lock.yaml +yarn.lock +routeTree.gen.ts +src/components/ui \ No newline at end of file diff --git a/web/frontend/components.json b/web/frontend/components.json new file mode 100644 index 000000000..9d5329694 --- /dev/null +++ b/web/frontend/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-vega", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "tabler", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/web/frontend/eslint.config.js b/web/frontend/eslint.config.js new file mode 100644 index 000000000..bc9c64344 --- /dev/null +++ b/web/frontend/eslint.config.js @@ -0,0 +1,31 @@ +import js from "@eslint/js" +import eslintConfigPrettier from "eslint-config-prettier" +import reactHooks from "eslint-plugin-react-hooks" +import reactRefresh from "eslint-plugin-react-refresh" +import { defineConfig, globalIgnores } from "eslint/config" +import globals from "globals" +import tseslint from "typescript-eslint" + +export default defineConfig([ + globalIgnores(["dist", "src/components/ui", "src/routeTree.gen.ts"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + eslintConfigPrettier, + ], + languageOptions: { + ecmaVersion: "latest", + globals: globals.browser, + }, + rules: { + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + }, + }, +]) diff --git a/web/frontend/index.html b/web/frontend/index.html new file mode 100644 index 000000000..d3bdd90f8 --- /dev/null +++ b/web/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + PicoClaw + + + +
+ + + diff --git a/web/frontend/package.json b/web/frontend/package.json new file mode 100644 index 000000000..373b4d468 --- /dev/null +++ b/web/frontend/package.json @@ -0,0 +1,63 @@ +{ + "name": "picoclaw-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "build:backend": "tsc -b && vite build --outDir ../backend/dist --emptyOutDir", + "lint": "eslint .", + "preview": "vite preview", + "format": "prettier --check .", + "check": "prettier --write . && eslint --fix" + }, + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@tabler/icons-react": "^3.38.0", + "@tailwindcss/vite": "^4.2.1", + "@tanstack/react-query": "^5.90.21", + "@tanstack/react-router": "^1.163.3", + "@tanstack/react-router-devtools": "^1.163.3", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "dayjs": "^1.11.19", + "i18next": "^25.8.14", + "i18next-browser-languagedetector": "^8.2.1", + "jotai": "^2.18.0", + "radix-ui": "^1.4.3", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-i18next": "^16.5.4", + "react-markdown": "^10.1.0", + "react-textarea-autosize": "^8.5.9", + "remark-gfm": "^4.0.1", + "shadcn": "^4.0.5", + "sonner": "^2.0.7", + "tailwind-merge": "^3.5.0", + "tailwindcss": "^4.2.1", + "tw-animate-css": "^1.4.0", + "wrap-ansi": "^10.0.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/typography": "^0.5.19", + "@tanstack/router-plugin": "^1.164.0", + "@trivago/prettier-plugin-sort-imports": "^6.0.2", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "prettier": "^3.8.1", + "prettier-plugin-tailwindcss": "^0.7.2", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1" + } +} diff --git a/web/frontend/pnpm-lock.yaml b/web/frontend/pnpm-lock.yaml new file mode 100644 index 000000000..75acacfa5 --- /dev/null +++ b/web/frontend/pnpm-lock.yaml @@ -0,0 +1,7988 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fontsource-variable/inter': + specifier: ^5.2.8 + version: 5.2.8 + '@tabler/icons-react': + specifier: ^3.38.0 + version: 3.38.0(react@19.2.4) + '@tailwindcss/vite': + specifier: ^4.2.1 + version: 4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.2.4) + '@tanstack/react-router': + specifier: ^1.163.3 + version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-router-devtools': + specifier: ^1.163.3 + version: 1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + dayjs: + specifier: ^1.11.19 + version: 1.11.19 + i18next: + specifier: ^25.8.14 + version: 25.8.14(typescript@5.9.3) + i18next-browser-languagedetector: + specifier: ^8.2.1 + version: 8.2.1 + jotai: + specifier: ^2.18.0 + version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4) + radix-ui: + specifier: ^1.4.3 + version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.2.0 + version: 19.2.4 + react-dom: + specifier: ^19.2.0 + version: 19.2.4(react@19.2.4) + react-i18next: + specifier: ^16.5.4 + version: 16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.2.14)(react@19.2.4) + react-textarea-autosize: + specifier: ^8.5.9 + version: 8.5.9(@types/react@19.2.14)(react@19.2.4) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 + shadcn: + specifier: ^4.0.5 + version: 4.0.5(@types/node@24.11.0)(typescript@5.9.3) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + tailwindcss: + specifier: ^4.2.1 + version: 4.2.1 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + wrap-ansi: + specifier: ^10.0.0 + version: 10.0.0 + devDependencies: + '@eslint/js': + specifier: ^9.39.1 + version: 9.39.3 + '@tailwindcss/typography': + specifier: ^0.5.19 + version: 0.5.19(tailwindcss@4.2.1) + '@tanstack/router-plugin': + specifier: ^1.164.0 + version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + '@trivago/prettier-plugin-sort-imports': + specifier: ^6.0.2 + version: 6.0.2(prettier@3.8.1) + '@types/node': + specifier: ^24.10.1 + version: 24.11.0 + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@typescript-eslint/eslint-plugin': + specifier: ^8.56.1 + version: 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)) + eslint: + specifier: ^9.39.1 + version: 9.39.3(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-react-hooks: + specifier: ^7.0.1 + version: 7.0.1(eslint@9.39.3(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.4.24 + version: 0.4.26(eslint@9.39.3(jiti@2.6.1)) + globals: + specifier: ^16.5.0 + version: 16.5.0 + prettier: + specifier: ^3.8.1 + version: 3.8.1 + 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) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.48.0 + version: 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + +packages: + + '@antfu/ni@25.0.0': + resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} + hasBin: true + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.27.3': + resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.28.6': + resolution: {integrity: sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.28.5': + resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.27.1': + resolution: {integrity: sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.28.6': + resolution: {integrity: sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + resolution: {integrity: sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.6': + resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.0': + resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.28.6': + resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.28.6': + resolution: {integrity: sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.28.6': + resolution: {integrity: sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.28.6': + resolution: {integrity: sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.28.5': + resolution: {integrity: sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.28.6': + resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@dotenvx/dotenvx@1.52.0': + resolution: {integrity: sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w==} + hasBin: true + + '@ecies/ciphers@0.2.5': + resolution: {integrity: sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + peerDependencies: + '@noble/ciphers': ^1.0.0 + + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.1': + resolution: {integrity: sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.4': + resolution: {integrity: sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.3': + resolution: {integrity: sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@floating-ui/core@1.7.4': + resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + + '@floating-ui/dom@1.7.5': + resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + + '@floating-ui/react-dom@2.1.7': + resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + + '@fontsource-variable/inter@5.2.8': + resolution: {integrity: sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==} + + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@humanfs/core@0.19.1': + resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.7': + resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.27.1': + resolution: {integrity: sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@mswjs/interceptors@0.41.3': + resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==} + engines: {node: '>=18'} + + '@noble/ciphers@1.3.0': + resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@open-draft/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + + '@radix-ui/react-accessible-icon@1.1.7': + resolution: {integrity: sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.12': + resolution: {integrity: sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.15': + resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.7': + resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.7': + resolution: {integrity: sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.1.10': + resolution: {integrity: sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.3': + resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.12': + resolution: {integrity: sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.2': + resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.2.16': + resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.1.2': + resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.15': + resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.11': + resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.16': + resolution: {integrity: sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.3': + resolution: {integrity: sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.7': + resolution: {integrity: sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.8': + resolution: {integrity: sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.15': + resolution: {integrity: sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.1': + resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.16': + resolution: {integrity: sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.16': + resolution: {integrity: sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.8': + resolution: {integrity: sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.3': + resolution: {integrity: sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.15': + resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.2.8': + resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.9': + resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.5': + resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.3': + resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.7': + resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.11': + resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.10': + resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.2.6': + resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.7': + resolution: {integrity: sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.3.6': + resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.2.3': + resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.2.6': + resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.13': + resolution: {integrity: sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.15': + resolution: {integrity: sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.11': + resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.11': + resolution: {integrity: sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.8': + resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.1': + resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.2': + resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.2': + resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.1': + resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.0': + resolution: {integrity: sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.1': + resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.1': + resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.3': + resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.1': + resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@tabler/icons-react@3.38.0': + resolution: {integrity: sha512-kR5wv+m4+GgmnSszg3rQd6SrTFAQ/XnQC/yTwIfuRJSfqB12KoIC7fPbIijFgOHTFlBN5DARnN0IVrR7KYG6/A==} + peerDependencies: + react: '>= 16' + + '@tabler/icons@3.38.0': + resolution: {integrity: sha512-FdETQSpQ3lN7BEjEUzjKhsfTDCamrvMDops4HEMphTm3DmkIFpThoODn8XXZ8Q9MhjshIvphIYVHHB7zpq167w==} + + '@tailwindcss/node@4.2.1': + resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==} + + '@tailwindcss/oxide-android-arm64@4.2.1': + resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.1': + resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.1': + resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==} + engines: {node: '>= 20'} + + '@tailwindcss/typography@0.5.19': + resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + + '@tailwindcss/vite@4.2.1': + resolution: {integrity: sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 + + '@tanstack/history@1.161.4': + resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router-devtools@1.163.3': + resolution: {integrity: sha512-42VMkV/2Z8ro7xzblPBRNZIEmCNXMzm2jD68G52p2qhjXm38wGpg46qneAESN9FtTQeVWk5aSXs47/jt7lkzmw==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/react-router': ^1.163.3 + '@tanstack/router-core': ^1.163.3 + 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.163.3': + resolution: {integrity: sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.1': + resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==} + peerDependencies: + 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.163.3': + resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==} + engines: {node: '>=20.19'} + + '@tanstack/router-devtools-core@1.163.3': + resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==} + engines: {node: '>=20.19'} + peerDependencies: + '@tanstack/router-core': ^1.163.3 + csstype: ^3.0.10 + peerDependenciesMeta: + csstype: + optional: true + + '@tanstack/router-generator@1.164.0': + resolution: {integrity: sha512-Uiyj+RtW0kdeqEd8NEd3Np1Z2nhJ2xgLS8U+5mTvFrm/s3xkM2LYjJHoLzc6am7sKPDsmeF9a4/NYq3R7ZJP0Q==} + engines: {node: '>=20.19'} + + '@tanstack/router-plugin@1.164.0': + resolution: {integrity: sha512-cZPsEMhqzyzmuPuDbsTAzBZaT+cj0pGjwdhjxJfPCM06Ax8v4tFR7n/Ug0UCwnNAUEmKZWN3lA9uT+TxXnk9PQ==} + engines: {node: '>=20.19'} + peerDependencies: + '@rsbuild/core': '>=1.0.2' + '@tanstack/react-router': ^1.163.3 + vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' + vite-plugin-solid: ^2.11.10 + webpack: '>=5.92.0' + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@tanstack/react-router': + optional: true + vite: + optional: true + vite-plugin-solid: + optional: true + webpack: + optional: true + + '@tanstack/router-utils@1.161.4': + resolution: {integrity: sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.1': + resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} + + '@tanstack/virtual-file-routes@1.161.4': + resolution: {integrity: sha512-42WoRePf8v690qG8yGRe/YOh+oHni9vUaUUfoqlS91U2scd3a5rkLtVsc6b7z60w3RogH0I00vdrC5AaeiZ18w==} + engines: {node: '>=20.19'} + + '@trivago/prettier-plugin-sort-imports@6.0.2': + resolution: {integrity: sha512-3DgfkukFyC/sE/VuYjaUUWoFfuVjPK55vOFDsxD56XXynFMCZDYFogH2l/hDfOsQAm1myoU/1xByJ3tWqtulXA==} + engines: {node: '>= 20'} + peerDependencies: + '@vue/compiler-sfc': 3.x + prettier: 2.x - 3.x + prettier-plugin-ember-template-tag: '>= 2.0.0' + prettier-plugin-svelte: 3.x + svelte: 4.x || 5.x + peerDependenciesMeta: + '@vue/compiler-sfc': + optional: true + prettier-plugin-ember-template-tag: + optional: true + prettier-plugin-svelte: + optional: true + svelte: + optional: true + + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@24.11.0': + resolution: {integrity: sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@typescript-eslint/eslint-plugin@8.56.1': + resolution: {integrity: sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.56.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/parser@8.56.1': + resolution: {integrity: sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==} + 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-eslint/project-service@8.56.1': + resolution: {integrity: sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/scope-manager@8.56.1': + resolution: {integrity: sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.56.1': + resolution: {integrity: sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/type-utils@8.56.1': + resolution: {integrity: sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==} + 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-eslint/types@8.56.1': + resolution: {integrity: sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.56.1': + resolution: {integrity: sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.0.0' + + '@typescript-eslint/utils@8.56.1': + resolution: {integrity: sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==} + 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-eslint/visitor-keys@8.56.1': + resolution: {integrity: sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-react@5.1.4': + resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@6.14.0: + resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} + + ajv@8.18.0: + resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + ansis@4.2.0: + resolution: {integrity: sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==} + engines: {node: '>=14'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + babel-dead-code-elimination@1.0.12: + resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.0: + resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} + engines: {node: '>=6.0.0'} + hasBin: true + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + + brace-expansion@5.0.4: + resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001775: + resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + content-disposition@1.0.1: + resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@2.0.0: + resolution: {integrity: sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + + dayjs@1.11.19: + resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + + dotenv@17.3.1: + resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + eciesjs@0.4.17: + resolution: {integrity: sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w==} + engines: {bun: '>=1', deno: '>=2', node: '>=16'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.302: + resolution: {integrity: sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.20.0: + resolution: {integrity: sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==} + engines: {node: '>=10.13.0'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + eslint-config-prettier@10.1.8: + resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + + eslint-plugin-react-hooks@7.0.1: + resolution: {integrity: sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==} + 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-plugin-react-refresh@0.4.26: + resolution: {integrity: sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==} + peerDependencies: + eslint: '>=8.40' + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.3: + resolution: {integrity: sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + express-rate-limit@8.3.1: + resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + fzf@0.5.2: + resolution: {integrity: sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + get-tsconfig@4.13.6: + resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + + goober@2.1.18: + resolution: {integrity: sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==} + peerDependencies: + csstype: ^3.0.10 + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + graphql@16.13.0: + resolution: {integrity: sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + headers-polyfill@4.0.3: + resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + engines: {node: '>=16.9.0'} + + html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + i18next-browser-languagedetector@8.2.1: + resolution: {integrity: sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==} + + i18next@25.8.14: + resolution: {integrity: sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==} + peerDependencies: + typescript: ^5 + peerDependenciesMeta: + typescript: + optional: true + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + ip-address@10.1.0: + resolution: {integrity: sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isbot@5.1.35: + resolution: {integrity: sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg==} + engines: {node: '>=18'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + javascript-natural-sort@0.7.1: + resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + + jotai@2.18.0: + resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.31.1: + resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.31.1: + resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.31.1: + resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.31.1: + resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.31.1: + resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.31.1: + resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.31.1: + resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.31.1: + resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.31.1: + resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.31.1: + resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.31.1: + resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.31.1: + resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash-es@4.17.23: + resolution: {integrity: sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-function@5.0.1: + 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@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + msw@2.12.10: + resolution: {integrity: sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-imports-exports@0.2.4: + resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parse-statements@1.0.11: + resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + path-to-regexp@8.3.0: + resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + postcss-selector-parser@6.0.10: + resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} + engines: {node: '>=4'} + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + prettier-plugin-tailwindcss@0.7.2: + resolution: {integrity: sha512-LkphyK3Fw+q2HdMOoiEHWf93fNtYJwfamoKPl7UwtjFQdei/iIBoX11G6j706FzN3ymX9mPVi97qIY8328vdnA==} + engines: {node: '>=20.19'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-hermes': '*' + '@prettier/plugin-oxc': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-multiline-arrays: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-hermes': + optional: true + '@prettier/plugin-oxc': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-multiline-arrays: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-svelte: + optional: true + + prettier@3.8.1: + resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} + engines: {node: '>=14'} + hasBin: true + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + property-information@7.1.0: + resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + qs@6.15.0: + resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix-ui@1.4.3: + resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-i18next@16.5.4: + resolution: {integrity: sha512-6yj+dcfMncEC21QPhOTsW8mOSO+pzFmT6uvU7XXdvM/Cp38zJkmTeMeKmTrmCMD5ToT79FmiE/mRWiYWcJYW4g==} + peerDependencies: + i18next: '>= 25.6.2' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-textarea-autosize@8.5.9: + resolution: {integrity: sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A==} + engines: {node: '>=10'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + + recast@0.23.11: + resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} + engines: {node: '>= 4'} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + rettime@0.10.1: + resolution: {integrity: sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + seroval-plugins@1.5.0: + resolution: {integrity: sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.5.0: + resolution: {integrity: sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==} + engines: {node: '>=10'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn@4.0.5: + resolution: {integrity: sha512-z0SOHEU1+ADam1UJHrgxJhUsOb0/jBoYc+u9mhWs071KrnORq48X7uCwG3mD2ysQEBtOfeK/MxMGsmzL5Jt+Jg==} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.0: + resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.1: + resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==} + + tapable@2.3.0: + resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tldts-core@7.0.23: + resolution: {integrity: sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==} + + tldts@7.0.23: + resolution: {integrity: sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + type-fest@5.4.4: + resolution: {integrity: sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw==} + engines: {node: '>=20'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + typescript-eslint@8.56.1: + resolution: {integrity: sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==} + 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@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-composed-ref@1.4.0: + resolution: {integrity: sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-latest@1.3.0: + resolution: {integrity: sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + wrap-ansi@10.0.0: + 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'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + 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'} + + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.3.6: + resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@antfu/ni@25.0.0': + dependencies: + ansis: 4.2.0 + fzf: 0.5.2 + package-manager-detector: 1.6.0 + tinyexec: 1.0.2 + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.28.6 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.27.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/traverse': 7.29.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-member-expression-to-functions@7.28.5': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.27.1': + dependencies: + '@babel/types': 7.29.0 + + '@babel/helper-plugin-utils@7.28.6': {} + + '@babel/helper-replace-supers@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-member-expression-to-functions': 7.28.5 + '@babel/helper-optimise-call-expression': 7.27.1 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.27.1': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.6': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.0': + dependencies: + '@babel/types': 7.29.0 + + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-typescript@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-annotate-as-pure': 7.27.3 + '@babel/helper-create-class-features-plugin': 7.28.6(@babel/core@7.29.0) + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.28.5(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + '@babel/helper-validator-option': 7.27.1 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + transitivePeerDependencies: + - supports-color + + '@babel/runtime@7.28.6': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@dotenvx/dotenvx@1.52.0': + dependencies: + commander: 11.1.0 + dotenv: 17.3.1 + eciesjs: 0.4.17 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.3) + ignore: 5.3.2 + object-treeify: 1.1.33 + picomatch: 4.0.3 + which: 4.0.0 + + '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': + dependencies: + '@noble/ciphers': 1.3.0 + + '@esbuild/aix-ppc64@0.27.3': + optional: true + + '@esbuild/android-arm64@0.27.3': + optional: true + + '@esbuild/android-arm@0.27.3': + optional: true + + '@esbuild/android-x64@0.27.3': + optional: true + + '@esbuild/darwin-arm64@0.27.3': + optional: true + + '@esbuild/darwin-x64@0.27.3': + optional: true + + '@esbuild/freebsd-arm64@0.27.3': + optional: true + + '@esbuild/freebsd-x64@0.27.3': + optional: true + + '@esbuild/linux-arm64@0.27.3': + optional: true + + '@esbuild/linux-arm@0.27.3': + optional: true + + '@esbuild/linux-ia32@0.27.3': + optional: true + + '@esbuild/linux-loong64@0.27.3': + optional: true + + '@esbuild/linux-mips64el@0.27.3': + optional: true + + '@esbuild/linux-ppc64@0.27.3': + optional: true + + '@esbuild/linux-riscv64@0.27.3': + optional: true + + '@esbuild/linux-s390x@0.27.3': + optional: true + + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + + '@esbuild/sunos-x64@0.27.3': + optional: true + + '@esbuild/win32-arm64@0.27.3': + optional: true + + '@esbuild/win32-ia32@0.27.3': + optional: true + + '@esbuild/win32-x64@0.27.3': + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.3(jiti@2.6.1))': + dependencies: + eslint: 9.39.3(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.1': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.4': + dependencies: + ajv: 6.14.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.3': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@floating-ui/core@1.7.4': + dependencies: + '@floating-ui/utils': 0.2.10 + + '@floating-ui/dom@1.7.5': + dependencies: + '@floating-ui/core': 1.7.4 + '@floating-ui/utils': 0.2.10 + + '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/dom': 1.7.5 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@floating-ui/utils@0.2.10': {} + + '@fontsource-variable/inter@5.2.8': {} + + '@hono/node-server@1.19.11(hono@4.12.7)': + dependencies: + hono: 4.12.7 + + '@humanfs/core@0.19.1': {} + + '@humanfs/node@0.16.7': + dependencies: + '@humanfs/core': 0.19.1 + '@humanwhocodes/retry': 0.4.3 + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/confirm@5.1.21(@types/node@24.11.0)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@24.11.0) + '@inquirer/type': 3.0.10(@types/node@24.11.0) + optionalDependencies: + '@types/node': 24.11.0 + + '@inquirer/core@10.3.2(@types/node@24.11.0)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@24.11.0) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 24.11.0 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/type@3.0.10(@types/node@24.11.0)': + optionalDependencies: + '@types/node': 24.11.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.27.1(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.11(hono@4.12.7) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.3.1(express@5.2.1) + hono: 4.12.7 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@mswjs/interceptors@0.41.3': + dependencies: + '@open-draft/deferred-promise': 2.2.0 + '@open-draft/logger': 0.3.0 + '@open-draft/until': 2.1.0 + is-node-process: 1.2.0 + outvariant: 1.4.3 + strict-event-emitter: 0.5.1 + + '@noble/ciphers@1.3.0': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + + '@radix-ui/number@1.1.1': {} + + '@radix-ui/primitive@1.1.3': {} + + '@radix-ui/react-accessible-icon@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-accordion@1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-aspect-ratio@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-avatar@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collapsible@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-context@1.1.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-dropdown-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-focus-guards@1.1.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-focus-scope@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-form@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-hover-card@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-id@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-label@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menu@2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-menubar@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-one-time-password-field@0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-password-toggle-field@0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-popper@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-rect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-portal@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-presence@1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-progress@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + aria-hidden: 1.2.6 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-separator@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slider@1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tabs@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toast@1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-toolbar@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-is-hydrated@0.1.0(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/rect': 1.1.1 + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-use-size@1.1.1(@types/react@19.2.14)(react@19.2.4)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + '@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@radix-ui/rect@1.1.1': {} + + '@rolldown/pluginutils@1.0.0-rc.3': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@tabler/icons-react@3.38.0(react@19.2.4)': + dependencies: + '@tabler/icons': 3.38.0 + react: 19.2.4 + + '@tabler/icons@3.38.0': {} + + '@tailwindcss/node@4.2.1': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.20.0 + jiti: 2.6.1 + lightningcss: 1.31.1 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.1 + + '@tailwindcss/oxide-android-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.1': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.1': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.1': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.1': + optional: true + + '@tailwindcss/oxide@4.2.1': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-arm64': 4.2.1 + '@tailwindcss/oxide-darwin-x64': 4.2.1 + '@tailwindcss/oxide-freebsd-x64': 4.2.1 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.1 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.1 + '@tailwindcss/oxide-linux-x64-musl': 4.2.1 + '@tailwindcss/oxide-wasm32-wasi': 4.2.1 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.1 + + '@tailwindcss/typography@0.5.19(tailwindcss@4.2.1)': + dependencies: + postcss-selector-parser: 6.0.10 + tailwindcss: 4.2.1 + + '@tailwindcss/vite@4.2.1(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + dependencies: + '@tailwindcss/node': 4.2.1 + '@tailwindcss/oxide': 4.2.1 + tailwindcss: 4.2.1 + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + + '@tanstack/history@1.161.4': {} + + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.4 + + '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@tanstack/router-core': 1.163.3 + transitivePeerDependencies: + - csstype + + '@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-core': 1.163.3 + isbot: 5.1.35 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/react-store@0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@tanstack/store': 0.9.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + + '@tanstack/router-core@1.163.3': + dependencies: + '@tanstack/history': 1.161.4 + '@tanstack/store': 0.9.1 + cookie-es: 2.0.0 + seroval: 1.5.0 + seroval-plugins: 1.5.0(seroval@1.5.0) + tiny-invariant: 1.3.3 + tiny-warning: 1.0.3 + + '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3)': + dependencies: + '@tanstack/router-core': 1.163.3 + clsx: 2.1.1 + goober: 2.1.18(csstype@3.2.3) + tiny-invariant: 1.3.3 + optionalDependencies: + csstype: 3.2.3 + + '@tanstack/router-generator@1.164.0': + dependencies: + '@tanstack/router-core': 1.163.3 + '@tanstack/router-utils': 1.161.4 + '@tanstack/virtual-file-routes': 1.161.4 + prettier: 3.8.1 + recast: 0.23.11 + source-map: 0.7.6 + tsx: 4.21.0 + zod: 3.25.76 + transitivePeerDependencies: + - supports-color + + '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) + '@babel/plugin-syntax-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@tanstack/router-core': 1.163.3 + '@tanstack/router-generator': 1.164.0 + '@tanstack/router-utils': 1.161.4 + '@tanstack/virtual-file-routes': 1.161.4 + chokidar: 3.6.0 + unplugin: 2.3.11 + zod: 3.25.76 + optionalDependencies: + '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + '@tanstack/router-utils@1.161.4': + dependencies: + '@babel/core': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + ansis: 4.2.0 + babel-dead-code-elimination: 1.0.12 + diff: 8.0.3 + pathe: 2.0.3 + tinyglobby: 0.2.15 + transitivePeerDependencies: + - supports-color + + '@tanstack/store@0.9.1': {} + + '@tanstack/virtual-file-routes@1.161.4': {} + + '@trivago/prettier-plugin-sort-imports@6.0.2(prettier@3.8.1)': + dependencies: + '@babel/generator': 7.29.1 + '@babel/parser': 7.29.0 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + javascript-natural-sort: 0.7.1 + lodash-es: 4.17.23 + minimatch: 9.0.9 + parse-imports-exports: 0.2.4 + prettier: 3.8.1 + transitivePeerDependencies: + - supports-color + + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.4 + path-browserify: 1.0.1 + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.8 + + '@types/estree@1.0.8': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/json-schema@7.0.15': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/node@24.11.0': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@types/statuses@2.0.6': {} + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@types/validate-npm-package-name@4.0.2': {} + + '@typescript-eslint/eslint-plugin@8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/type-utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + eslint: 9.39.3(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + + '@typescript-eslint/tsconfig-utils@8.56.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.3(jiti@2.6.1) + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.56.1': {} + + '@typescript-eslint/typescript-estree@8.56.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.56.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.56.1(typescript@5.9.3) + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/visitor-keys': 8.56.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.4 + tinyglobby: 0.2.15 + ts-api-utils: 2.4.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.56.1 + '@typescript-eslint/types': 8.56.1 + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.56.1': + dependencies: + '@typescript-eslint/types': 8.56.1 + eslint-visitor-keys: 5.0.1 + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0) + transitivePeerDependencies: + - supports-color + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + agent-base@7.1.4: {} + + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + + ajv@6.14.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ajv@8.18.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + ansis@4.2.0: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + babel-dead-code-elimination@1.0.12: + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + bail@2.0.2: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.0: {} + + binary-extensions@2.3.0: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.0 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + brace-expansion@1.1.12: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.4: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.10.0 + caniuse-lite: 1.0.30001775 + electron-to-chromium: 1.5.302 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001775: {} + + ccount@2.0.1: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + code-block-writer@13.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + commander@14.0.3: {} + + concat-map@0.0.1: {} + + content-disposition@1.0.1: {} + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-es@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + data-uri-to-buffer@4.0.1: {} + + dayjs@1.11.19: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dedent@1.7.2: {} + + deep-is@0.1.4: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@3.0.0: {} + + depd@2.0.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.3: {} + + dotenv@17.3.1: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + eciesjs@0.4.17: + dependencies: + '@ecies/ciphers': 0.2.5(@noble/ciphers@1.3.0) + '@noble/ciphers': 1.3.0 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.302: {} + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.20.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.0 + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + eslint-config-prettier@10.1.8(eslint@9.39.3(jiti@2.6.1)): + dependencies: + eslint: 9.39.3(jiti@2.6.1) + + eslint-plugin-react-hooks@7.0.1(eslint@9.39.3(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + eslint: 9.39.3(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.4.26(eslint@9.39.3(jiti@2.6.1)): + dependencies: + eslint: 9.39.3(jiti@2.6.1) + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.3(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.3(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.1 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.4 + '@eslint/js': 9.39.3 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.7 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.14.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esprima@4.0.1: {} + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + esutils@2.0.3: {} + + etag@1.8.1: {} + + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + express-rate-limit@8.3.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.1.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.0.1 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.0 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fast-uri@3.1.0: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.3.3 + keyv: 4.5.4 + + flatted@3.3.3: {} + + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + fuzzysort@3.1.0: {} + + fzf@0.5.2: {} + + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.5.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-own-enumerable-keys@1.0.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + get-tsconfig@4.13.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + goober@2.1.18(csstype@3.2.3): + dependencies: + csstype: 3.2.3 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + graphql@16.13.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.8 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.1.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + headers-polyfill@4.0.3: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + hono@4.12.7: {} + + html-parse-stringify@3.0.1: + dependencies: + void-elements: 3.1.0 + + html-url-attributes@3.0.1: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + i18next-browser-languagedetector@8.2.1: + dependencies: + '@babel/runtime': 7.28.6 + + i18next@25.8.14(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + optionalDependencies: + typescript: 5.9.3 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + inherits@2.0.4: {} + + inline-style-parser@0.2.7: {} + + ip-address@10.1.0: {} + + ipaddr.js@1.9.1: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-arrayish@0.2.1: {} + + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-decimal@2.0.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-hexadecimal@2.0.1: {} + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-node-process@1.2.0: {} + + is-number@7.0.0: {} + + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-regexp@3.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isbot@5.1.35: {} + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + javascript-natural-sort@0.7.1: {} + + jiti@2.6.1: {} + + jose@6.1.3: {} + + jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.4): + optionalDependencies: + '@babel/core': 7.29.0 + '@babel/template': 7.28.6 + '@types/react': 19.2.14 + react: 19.2.4 + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@0.4.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.31.1: + optional: true + + lightningcss-darwin-arm64@1.31.1: + optional: true + + lightningcss-darwin-x64@1.31.1: + optional: true + + lightningcss-freebsd-x64@1.31.1: + optional: true + + lightningcss-linux-arm-gnueabihf@1.31.1: + optional: true + + lightningcss-linux-arm64-gnu@1.31.1: + optional: true + + lightningcss-linux-arm64-musl@1.31.1: + optional: true + + lightningcss-linux-x64-gnu@1.31.1: + optional: true + + lightningcss-linux-x64-musl@1.31.1: + optional: true + + lightningcss-win32-arm64-msvc@1.31.1: + optional: true + + lightningcss-win32-x64-msvc@1.31.1: + optional: true + + lightningcss@1.31.1: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.31.1 + lightningcss-darwin-arm64: 1.31.1 + lightningcss-darwin-x64: 1.31.1 + lightningcss-freebsd-x64: 1.31.1 + lightningcss-linux-arm-gnueabihf: 1.31.1 + lightningcss-linux-arm64-gnu: 1.31.1 + lightningcss-linux-arm64-musl: 1.31.1 + lightningcss-linux-x64-gnu: 1.31.1 + lightningcss-linux-x64-musl: 1.31.1 + lightningcss-win32-arm64-msvc: 1.31.1 + lightningcss-win32-x64-msvc: 1.31.1 + + lines-and-columns@1.2.4: {} + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash-es@4.17.23: {} + + lodash.merge@4.6.2: {} + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + longest-streak@3.1.0: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + markdown-table@3.0.4: {} + + math-intrinsics@1.1.0: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.12 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.4: + dependencies: + brace-expansion: 5.0.4 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.12 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.0.2 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + msw@2.12.10(@types/node@24.11.0)(typescript@5.9.3): + dependencies: + '@inquirer/confirm': 5.1.21(@types/node@24.11.0) + '@mswjs/interceptors': 0.41.3 + '@open-draft/deferred-promise': 2.2.0 + '@types/statuses': 2.0.6 + cookie: 1.1.1 + graphql: 16.13.0 + headers-polyfill: 4.0.3 + is-node-process: 1.2.0 + outvariant: 1.4.3 + path-to-regexp: 6.3.0 + picocolors: 1.1.1 + rettime: 0.10.1 + statuses: 2.0.2 + strict-event-emitter: 0.5.1 + tough-cookie: 6.0.0 + type-fest: 5.4.4 + until-async: 3.0.2 + yargs: 17.7.2 + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@types/node' + + mute-stream@2.0.0: {} + + nanoid@3.3.11: {} + + natural-compare@1.4.0: {} + + negotiator@1.0.0: {} + + node-domexception@1.0.0: {} + + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-treeify@1.1.33: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + outvariant@1.4.3: {} + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + package-manager-detector@1.6.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-imports-exports@0.2.4: + dependencies: + parse-statements: 1.0.11 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parse-statements@1.0.11: {} + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-to-regexp@6.3.0: {} + + path-to-regexp@8.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + pkce-challenge@5.0.1: {} + + postcss-selector-parser@6.0.10: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + 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): + dependencies: + prettier: 3.8.1 + optionalDependencies: + '@trivago/prettier-plugin-sort-imports': 6.0.2(prettier@3.8.1) + + prettier@3.8.1: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + property-information@7.1.0: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + qs@6.15.0: + dependencies: + side-channel: 1.1.0 + + queue-microtask@1.2.3: {} + + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-accessible-icon': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-accordion': 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-alert-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-arrow': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-aspect-ratio': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-avatar': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-checkbox': 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collapsible': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-context-menu': 2.2.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-dropdown-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-form': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-hover-card': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-label': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menu': 2.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-menubar': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-navigation-menu': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-one-time-password-field': 0.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-password-toggle-field': 0.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popover': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-progress': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-radio-group': 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-scroll-area': 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-select': 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-separator': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slider': 1.3.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-switch': 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tabs': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toast': 1.2.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toggle-group': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-toolbar': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-tooltip': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-escape-keydown': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-is-hydrated': 0.1.0(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-i18next@16.5.4(i18next@25.8.14(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.28.6 + html-parse-stringify: 3.0.1 + i18next: 25.8.14(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + react-dom: 19.2.4(react@19.2.4) + typescript: 5.9.3 + + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/react': 19.2.14 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.2.4 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-refresh@0.18.0: {} + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.4) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.4) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.4) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + get-nonce: 1.0.1 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-textarea-autosize@8.5.9(@types/react@19.2.14)(react@19.2.4): + dependencies: + '@babel/runtime': 7.28.6 + react: 19.2.4 + use-composed-ref: 1.4.0(@types/react@19.2.14)(react@19.2.4) + use-latest: 1.3.0(@types/react@19.2.14)(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + + react@19.2.4: {} + + readdirp@3.6.0: + dependencies: + picomatch: 2.3.1 + + recast@0.23.11: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + rettime@0.10.1: {} + + reusify@1.1.0: {} + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.3.0 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + seroval-plugins@1.5.0(seroval@1.5.0): + dependencies: + seroval: 1.5.0 + + seroval@1.5.0: {} + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shadcn@4.0.5(@types/node@24.11.0)(typescript@5.9.3): + dependencies: + '@antfu/ni': 25.0.0 + '@babel/core': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/plugin-transform-typescript': 7.28.6(@babel/core@7.29.0) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.0) + '@dotenvx/dotenvx': 1.52.0 + '@modelcontextprotocol/sdk': 1.27.1(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.1 + commander: 14.0.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + dedent: 1.7.2 + deepmerge: 4.3.1 + diff: 8.0.3 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.3 + fuzzysort: 3.1.0 + https-proxy-agent: 7.0.6 + kleur: 4.1.5 + msw: 2.12.10(@types/node@24.11.0)(typescript@5.9.3) + node-fetch: 3.3.2 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.6 + postcss-selector-parser: 7.1.1 + prompts: 2.4.2 + recast: 0.23.11 + stringify-object: 5.0.0 + tailwind-merge: 3.5.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - '@types/node' + - babel-plugin-macros + - supports-color + - typescript + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.0 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + sonner@2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + source-map@0.7.6: {} + + space-separated-tokens@2.0.2: {} + + statuses@2.0.2: {} + + stdin-discarder@0.2.2: {} + + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + strip-json-comments@3.1.1: {} + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tagged-tag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.1: {} + + tapable@2.3.0: {} + + tiny-invariant@1.3.3: {} + + tiny-warning@1.0.3: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tldts-core@7.0.23: {} + + tldts@7.0.23: + dependencies: + tldts-core: 7.0.23 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.23 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@2.4.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + + tw-animate-css@1.4.0: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + type-fest@5.4.4: + dependencies: + tagged-tag: 1.0.0 + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript-eslint@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.56.1(@typescript-eslint/parser@8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.56.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.56.1(eslint@9.39.3(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.3(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + unicorn-magic@0.3.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.16.0 + picomatch: 4.0.3 + webpack-virtual-modules: 0.6.2 + + until-async@3.0.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-composed-ref@1.4.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.14 + + use-latest@1.3.0(@types/react@19.2.14)(react@19.2.4): + dependencies: + react: 19.2.4 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.14)(react@19.2.4) + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.4): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.4 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + + util-deprecate@1.0.2: {} + + validate-npm-package-name@7.0.2: {} + + vary@1.1.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@7.3.1(@types/node@24.11.0)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.11.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.31.1 + tsx: 4.21.0 + + void-elements@3.1.0: {} + + web-streams-polyfill@3.3.3: {} + + webpack-virtual-modules@0.6.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + word-wrap@1.2.5: {} + + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + 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 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.2: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.25.1(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-validation-error@4.0.2(zod@4.3.6): + dependencies: + zod: 4.3.6 + + zod@3.25.76: {} + + zod@4.3.6: {} + + zwitch@2.0.4: {} diff --git a/web/frontend/prettier.config.js b/web/frontend/prettier.config.js new file mode 100644 index 000000000..492ef1dd7 --- /dev/null +++ b/web/frontend/prettier.config.js @@ -0,0 +1,17 @@ +// @ts-check + +/** @type {import('prettier').Config} */ +const config = { + semi: false, + printWidth: 80, + tabWidth: 2, + importOrder: ["", "", "^@/", "^[./]"], + importOrderSeparation: true, + importOrderSortSpecifiers: true, + plugins: [ + "@trivago/prettier-plugin-sort-imports", + "prettier-plugin-tailwindcss", + ], +} + +export default config diff --git a/web/frontend/public/apple-touch-icon.png b/web/frontend/public/apple-touch-icon.png new file mode 100644 index 000000000..d881c64af Binary files /dev/null and b/web/frontend/public/apple-touch-icon.png differ diff --git a/web/frontend/public/favicon-96x96.png b/web/frontend/public/favicon-96x96.png new file mode 100644 index 000000000..5bdeccea5 Binary files /dev/null and b/web/frontend/public/favicon-96x96.png differ diff --git a/web/frontend/public/favicon.ico b/web/frontend/public/favicon.ico new file mode 100644 index 000000000..8b46b4b26 Binary files /dev/null and b/web/frontend/public/favicon.ico differ diff --git a/web/frontend/public/favicon.svg b/web/frontend/public/favicon.svg new file mode 100644 index 000000000..e2f412b70 --- /dev/null +++ b/web/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/frontend/public/lark.svg b/web/frontend/public/lark.svg new file mode 100644 index 000000000..0761f278f --- /dev/null +++ b/web/frontend/public/lark.svg @@ -0,0 +1 @@ + diff --git a/web/frontend/public/logo_with_text.png b/web/frontend/public/logo_with_text.png new file mode 100644 index 000000000..70f26788c Binary files /dev/null and b/web/frontend/public/logo_with_text.png differ diff --git a/web/frontend/public/site.webmanifest b/web/frontend/public/site.webmanifest new file mode 100644 index 000000000..981d97f15 --- /dev/null +++ b/web/frontend/public/site.webmanifest @@ -0,0 +1,21 @@ +{ + "name": "MyWebSite", + "short_name": "MySite", + "icons": [ + { + "src": "/web-app-manifest-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "/web-app-manifest-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ], + "theme_color": "#ffffff", + "background_color": "#ffffff", + "display": "standalone" +} diff --git a/web/frontend/public/web-app-manifest-192x192.png b/web/frontend/public/web-app-manifest-192x192.png new file mode 100644 index 000000000..01933339b Binary files /dev/null and b/web/frontend/public/web-app-manifest-192x192.png differ diff --git a/web/frontend/public/web-app-manifest-512x512.png b/web/frontend/public/web-app-manifest-512x512.png new file mode 100644 index 000000000..e0b4aab9c Binary files /dev/null and b/web/frontend/public/web-app-manifest-512x512.png differ diff --git a/web/frontend/src/api/channels.ts b/web/frontend/src/api/channels.ts new file mode 100644 index 000000000..ecd77632c --- /dev/null +++ b/web/frontend/src/api/channels.ts @@ -0,0 +1,65 @@ +// API client for channels navigation and channel-specific config flows. + +export type ChannelConfig = Record +export type AppConfig = Record + +export interface SupportedChannel { + name: string + display_name?: string + config_key: string + variant?: string +} + +interface ChannelsCatalogResponse { + channels: SupportedChannel[] +} + +interface ConfigActionResponse { + status: string + errors?: string[] +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + let message = `API error: ${res.status} ${res.statusText}` + try { + const body = (await res.json()) as { + error?: string + errors?: string[] + status?: string + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + message = body.errors.join("; ") + } else if (typeof body.error === "string" && body.error.trim() !== "") { + message = body.error + } + } catch { + // Keep default fallback message if response body is not JSON. + } + throw new Error(message) + } + return res.json() as Promise +} + +export async function getChannelsCatalog(): Promise { + return request("/api/channels/catalog") +} + +export async function getAppConfig(): Promise { + return request("/api/config") +} + +export async function patchAppConfig( + patch: Record, +): Promise { + return request("/api/config", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }) +} + +export type { ChannelsCatalogResponse, ConfigActionResponse } diff --git a/web/frontend/src/api/gateway.ts b/web/frontend/src/api/gateway.ts new file mode 100644 index 000000000..020e92e3a --- /dev/null +++ b/web/frontend/src/api/gateway.ts @@ -0,0 +1,70 @@ +// API client for gateway process management. + +interface GatewayStatusResponse { + gateway_status: "running" | "starting" | "stopped" | "error" + gateway_start_allowed?: boolean + gateway_start_reason?: string + pid?: number + logs?: string[] + log_total?: number + log_run_id?: number + [key: string]: unknown +} + +interface GatewayActionResponse { + status: string + pid?: number + log_total?: number + log_run_id?: number +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + throw new Error(`API error: ${res.status} ${res.statusText}`) + } + return res.json() as Promise +} + +export async function getGatewayStatus(options?: { + log_offset?: number + log_run_id?: number +}): Promise { + const params = new URLSearchParams() + if (options?.log_offset !== undefined) { + params.set("log_offset", options.log_offset.toString()) + } + if (options?.log_run_id !== undefined) { + params.set("log_run_id", options.log_run_id.toString()) + } + const queryString = params.toString() ? `?${params.toString()}` : "" + return request(`/api/gateway/status${queryString}`) +} + +export async function startGateway(): Promise { + return request("/api/gateway/start", { + method: "POST", + }) +} + +export async function stopGateway(): Promise { + return request("/api/gateway/stop", { + method: "POST", + }) +} + +export async function restartGateway(): Promise { + return request("/api/gateway/restart", { + method: "POST", + }) +} + +export async function clearGatewayLogs(): Promise { + return request("/api/gateway/logs/clear", { + method: "POST", + }) +} + +export type { GatewayStatusResponse, GatewayActionResponse } diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts new file mode 100644 index 000000000..6a4544c65 --- /dev/null +++ b/web/frontend/src/api/models.ts @@ -0,0 +1,91 @@ +import { refreshGatewayState } from "@/store/gateway" + +// API client for model list management. + +export interface ModelInfo { + index: number + model_name: string + model: string + api_base?: string + api_key: string + proxy?: string + auth_method?: string + // Advanced fields + connect_mode?: string + workspace?: string + rpm?: number + max_tokens_field?: string + request_timeout?: number + thinking_level?: string + // Meta + configured: boolean + is_default: boolean +} + +interface ModelsListResponse { + models: ModelInfo[] + total: number + default_model: string +} + +interface ModelActionResponse { + status: string + index?: number + default_model?: string +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + throw new Error(`API error: ${res.status} ${res.statusText}`) + } + return res.json() as Promise +} + +export async function getModels(): Promise { + return request("/api/models") +} + +export async function addModel( + model: Partial, +): Promise { + return request("/api/models", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(model), + }) +} + +export async function updateModel( + index: number, + model: Partial, +): Promise { + return request(`/api/models/${index}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(model), + }) +} + +export async function deleteModel(index: number): Promise { + return request(`/api/models/${index}`, { + method: "DELETE", + }) +} + +export async function setDefaultModel( + modelName: string, +): Promise { + const response = await request("/api/models/default", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model_name: modelName }), + }) + + void refreshGatewayState() + return response +} + +export type { ModelsListResponse, ModelActionResponse } diff --git a/web/frontend/src/api/oauth.ts b/web/frontend/src/api/oauth.ts new file mode 100644 index 000000000..a1ed1afcb --- /dev/null +++ b/web/frontend/src/api/oauth.ts @@ -0,0 +1,102 @@ +export type OAuthProvider = "openai" | "anthropic" | "google-antigravity" +export type OAuthMethod = "browser" | "device_code" | "token" + +export interface OAuthProviderStatus { + provider: OAuthProvider + display_name: string + methods: OAuthMethod[] + logged_in: boolean + status: "connected" | "expired" | "needs_refresh" | "not_logged_in" + auth_method?: string + expires_at?: string + account_id?: string + email?: string + project_id?: string +} + +export interface OAuthFlowState { + flow_id: string + provider: OAuthProvider + method: OAuthMethod + status: "pending" | "success" | "error" | "expired" + expires_at?: string + error?: string + user_code?: string + verify_url?: string + interval?: number +} + +export interface OAuthLoginRequest { + provider: OAuthProvider + method: OAuthMethod + token?: string +} + +export interface OAuthLoginResponse { + status: string + provider: OAuthProvider + method: OAuthMethod + flow_id?: string + auth_url?: string + user_code?: string + verify_url?: string + interval?: number + expires_at?: string +} + +interface OAuthProvidersResponse { + providers: OAuthProviderStatus[] +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + const message = await res.text() + throw new Error(message || `API error: ${res.status} ${res.statusText}`) + } + return res.json() as Promise +} + +export async function getOAuthProviders(): Promise { + return request("/api/oauth/providers") +} + +export async function loginOAuth( + payload: OAuthLoginRequest, +): Promise { + return request("/api/oauth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) +} + +export async function getOAuthFlow(flowID: string): Promise { + return request( + `/api/oauth/flows/${encodeURIComponent(flowID)}`, + ) +} + +export async function pollOAuthFlow(flowID: string): Promise { + return request( + `/api/oauth/flows/${encodeURIComponent(flowID)}/poll`, + { + method: "POST", + }, + ) +} + +export async function logoutOAuth( + provider: OAuthProvider, +): Promise<{ status: string; provider: OAuthProvider }> { + return request<{ status: string; provider: OAuthProvider }>( + "/api/oauth/logout", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider }), + }, + ) +} diff --git a/web/frontend/src/api/pico.ts b/web/frontend/src/api/pico.ts new file mode 100644 index 000000000..9a1a553d5 --- /dev/null +++ b/web/frontend/src/api/pico.ts @@ -0,0 +1,38 @@ +// API client for Pico Channel configuration. + +interface PicoTokenResponse { + token: string + ws_url: string + enabled: boolean +} + +interface PicoSetupResponse { + token: string + ws_url: string + enabled: boolean + changed: boolean +} + +const BASE_URL = "" + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE_URL}${path}`, options) + if (!res.ok) { + throw new Error(`API error: ${res.status} ${res.statusText}`) + } + return res.json() as Promise +} + +export async function getPicoToken(): Promise { + return request("/api/pico/token") +} + +export async function regenPicoToken(): Promise { + return request("/api/pico/token", { method: "POST" }) +} + +export async function setupPico(): Promise { + return request("/api/pico/setup", { method: "POST" }) +} + +export type { PicoTokenResponse, PicoSetupResponse } diff --git a/web/frontend/src/api/sessions.ts b/web/frontend/src/api/sessions.ts new file mode 100644 index 000000000..10b0d28fd --- /dev/null +++ b/web/frontend/src/api/sessions.ts @@ -0,0 +1,51 @@ +// Sessions API — list and retrieve chat session history + +export interface SessionSummary { + id: string + title: string + preview: string + message_count: number + created: string + updated: string +} + +export interface SessionDetail { + id: string + messages: { role: "user" | "assistant"; content: string }[] + summary: string + created: string + updated: string +} + +export async function getSessions( + offset: number = 0, + limit: number = 20, +): Promise { + const params = new URLSearchParams({ + offset: offset.toString(), + limit: limit.toString(), + }) + + const res = await fetch(`/api/sessions?${params.toString()}`) + if (!res.ok) { + throw new Error(`Failed to fetch sessions: ${res.status}`) + } + return res.json() +} + +export async function getSessionHistory(id: string): Promise { + const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`) + if (!res.ok) { + throw new Error(`Failed to fetch session ${id}: ${res.status}`) + } + return res.json() +} + +export async function deleteSession(id: string): Promise { + const res = await fetch(`/api/sessions/${encodeURIComponent(id)}`, { + method: "DELETE", + }) + if (!res.ok) { + throw new Error(`Failed to delete session ${id}: ${res.status}`) + } +} diff --git a/web/frontend/src/api/skills.ts b/web/frontend/src/api/skills.ts new file mode 100644 index 000000000..307cbd788 --- /dev/null +++ b/web/frontend/src/api/skills.ts @@ -0,0 +1,79 @@ +export interface SkillSupportItem { + name: string + path: string + source: "workspace" | "global" | "builtin" | string + description: string +} + +export interface SkillDetailResponse extends SkillSupportItem { + content: string +} + +interface SkillsResponse { + skills: SkillSupportItem[] +} + +interface SkillActionResponse { + status?: string + name?: string + path?: string + source?: string + description?: string +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(path, options) + if (!res.ok) { + throw new Error(await extractErrorMessage(res)) + } + return res.json() as Promise +} + +export async function getSkills(): Promise { + return request("/api/skills") +} + +export async function getSkill(name: string): Promise { + return request(`/api/skills/${encodeURIComponent(name)}`) +} + +export async function importSkill(file: File): Promise { + const formData = new FormData() + formData.set("file", file) + + const res = await fetch("/api/skills/import", { + method: "POST", + body: formData, + }) + if (!res.ok) { + throw new Error(await extractErrorMessage(res)) + } + return res.json() as Promise +} + +export async function deleteSkill(name: string): Promise { + return request( + `/api/skills/${encodeURIComponent(name)}`, + { + method: "DELETE", + }, + ) +} + +async function extractErrorMessage(res: Response): Promise { + try { + const body = (await res.json()) as { + error?: string + errors?: string[] + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + return body.errors.join("; ") + } + if (typeof body.error === "string" && body.error.trim() !== "") { + return body.error + } + } catch { + // ignore invalid body + } + return `API error: ${res.status} ${res.statusText}` +} diff --git a/web/frontend/src/api/system.ts b/web/frontend/src/api/system.ts new file mode 100644 index 000000000..543c8694d --- /dev/null +++ b/web/frontend/src/api/system.ts @@ -0,0 +1,62 @@ +export interface AutoStartStatus { + enabled: boolean + supported: boolean + platform: string + message?: string +} + +export interface LauncherConfig { + port: number + public: boolean + allowed_cidrs: string[] +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(path, options) + if (!res.ok) { + let message = `API error: ${res.status} ${res.statusText}` + try { + const body = (await res.json()) as { + error?: string + errors?: string[] + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + message = body.errors.join("; ") + } else if (typeof body.error === "string" && body.error.trim() !== "") { + message = body.error + } + } catch { + // Keep fallback error message when response body is not JSON. + } + throw new Error(message) + } + return res.json() as Promise +} + +export async function getAutoStartStatus(): Promise { + return request("/api/system/autostart") +} + +export async function setAutoStartEnabled( + enabled: boolean, +): Promise { + return request("/api/system/autostart", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }) +} + +export async function getLauncherConfig(): Promise { + return request("/api/system/launcher-config") +} + +export async function setLauncherConfig( + payload: LauncherConfig, +): Promise { + return request("/api/system/launcher-config", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) +} diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts new file mode 100644 index 000000000..9f09efbfd --- /dev/null +++ b/web/frontend/src/api/tools.ts @@ -0,0 +1,56 @@ +export interface ToolSupportItem { + name: string + description: string + category: string + config_key: string + status: "enabled" | "disabled" | "blocked" + reason_code?: string +} + +interface ToolsResponse { + tools: ToolSupportItem[] +} + +interface ToolActionResponse { + status: string +} + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(path, options) + if (!res.ok) { + let message = `API error: ${res.status} ${res.statusText}` + try { + const body = (await res.json()) as { + error?: string + errors?: string[] + } + if (Array.isArray(body.errors) && body.errors.length > 0) { + message = body.errors.join("; ") + } else if (typeof body.error === "string" && body.error.trim() !== "") { + message = body.error + } + } catch { + // ignore invalid body + } + throw new Error(message) + } + return res.json() as Promise +} + +export async function getTools(): Promise { + return request("/api/tools") +} + +export async function setToolEnabled( + name: string, + enabled: boolean, +): Promise { + return request( + `/api/tools/${encodeURIComponent(name)}/state`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }, + ) +} diff --git a/web/frontend/src/components/app-header.tsx b/web/frontend/src/components/app-header.tsx new file mode 100644 index 000000000..7a50fe0fb --- /dev/null +++ b/web/frontend/src/components/app-header.tsx @@ -0,0 +1,193 @@ +import { + IconBook, + IconLanguage, + IconLoader2, + IconMenu2, + IconMoon, + IconPlayerPlay, + IconPower, + IconSun, +} from "@tabler/icons-react" +import { Link } from "@tanstack/react-router" +import * as React from "react" +import { useTranslation } from "react-i18next" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog.tsx" +import { Button } from "@/components/ui/button.tsx" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu.tsx" +import { Separator } from "@/components/ui/separator.tsx" +import { SidebarTrigger } from "@/components/ui/sidebar" +import { useGateway } from "@/hooks/use-gateway.ts" +import { useTheme } from "@/hooks/use-theme.ts" + +export function AppHeader() { + const { i18n, t } = useTranslation() + const { theme, toggleTheme } = useTheme() + const { + state: gwState, + loading: gwLoading, + canStart, + start, + stop, + } = useGateway() + + const isRunning = gwState === "running" + const isStarting = gwState === "starting" + const isStopped = gwState === "stopped" || gwState === "unknown" + const showNotConnectedHint = + canStart && (gwState === "stopped" || gwState === "error") + + const [showStopDialog, setShowStopDialog] = React.useState(false) + + const handleGatewayToggle = () => { + if (gwLoading || (!isRunning && !canStart)) return + if (isRunning) { + setShowStopDialog(true) + } else { + start() + } + } + + const confirmStop = () => { + setShowStopDialog(false) + stop() + } + + return ( +
+
+ + + +
+ + Logo + +
+
+ + {/* Center prominent connection status */} +
+ {showNotConnectedHint && ( +
+ + + + {t("chat.notConnected")} +
+ )} +
+ + + + + + {t("header.gateway.stopDialog.title")} + + + {t("header.gateway.stopDialog.description")} + + + + {t("common.cancel")} + + {t("header.gateway.stopDialog.confirm")} + + + + + +
+ {/* Gateway Start/Stop */} + + + + + {/* Docs Link */} + + + {/* Language Switcher */} + + + + + + i18n.changeLanguage("en")}> + English + + i18n.changeLanguage("zh")}> + 简体中文 + + + + + {/* Theme Toggle */} + +
+
+ ) +} diff --git a/web/frontend/src/components/app-layout.tsx b/web/frontend/src/components/app-layout.tsx new file mode 100644 index 000000000..ff9877bae --- /dev/null +++ b/web/frontend/src/components/app-layout.tsx @@ -0,0 +1,27 @@ +import type { ReactNode } from "react" +import { Toaster } from "sonner" + +import { AppHeader } from "@/components/app-header" +import { AppSidebar } from "@/components/app-sidebar" +import { SidebarProvider } from "@/components/ui/sidebar" +import { TooltipProvider } from "@/components/ui/tooltip" + +export function AppLayout({ children }: { children: ReactNode }) { + return ( + + + + +
+ +
+
+ {children} +
+
+
+ +
+
+ ) +} diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx new file mode 100644 index 000000000..702212857 --- /dev/null +++ b/web/frontend/src/components/app-sidebar.tsx @@ -0,0 +1,238 @@ +import { IconChevronRight } from "@tabler/icons-react" +import { + IconAtom, + IconChevronsDown, + IconChevronsUp, + IconKey, + IconListDetails, + IconMessageCircle, + IconSettings, + IconSparkles, + IconTools, +} from "@tabler/icons-react" +import { Link, useRouterState } from "@tanstack/react-router" +import * as React from "react" +import { useTranslation } from "react-i18next" + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible" +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarRail, +} from "@/components/ui/sidebar" +import { useSidebarChannels } from "@/hooks/use-sidebar-channels" + +interface NavItem { + title: string + url: string + icon: React.ComponentType<{ className?: string }> + translateTitle?: boolean +} + +interface NavGroup { + label: string + defaultOpen: boolean + items: NavItem[] + isChannelsGroup?: boolean +} + +const baseNavGroups: Omit[] = [ + { + label: "navigation.chat", + defaultOpen: true, + }, + { + label: "navigation.model_group", + defaultOpen: true, + }, + { + label: "navigation.agent_group", + defaultOpen: true, + }, + { + label: "navigation.services", + defaultOpen: true, + }, +] + +export function AppSidebar({ ...props }: React.ComponentProps) { + const routerState = useRouterState() + const { t } = useTranslation() + const currentPath = routerState.location.pathname + const { + channelItems, + hasMoreChannels, + showAllChannels, + toggleShowAllChannels, + } = useSidebarChannels({ t }) + + const navGroups: NavGroup[] = React.useMemo(() => { + return [ + { + ...baseNavGroups[0], + items: [ + { + title: "navigation.chat", + url: "/", + icon: IconMessageCircle, + translateTitle: true, + }, + ], + }, + { + ...baseNavGroups[1], + items: [ + { + title: "navigation.models", + url: "/models", + icon: IconAtom, + translateTitle: true, + }, + { + title: "navigation.credentials", + url: "/credentials", + icon: IconKey, + translateTitle: true, + }, + ], + }, + { + label: "navigation.channels_group", + defaultOpen: true, + items: channelItems.map((item) => ({ + title: item.title, + url: item.url, + icon: item.icon, + translateTitle: false, + })), + isChannelsGroup: true, + }, + { + ...baseNavGroups[2], + items: [ + { + title: "navigation.skills", + url: "/agent/skills", + icon: IconSparkles, + translateTitle: true, + }, + { + title: "navigation.tools", + url: "/agent/tools", + icon: IconTools, + translateTitle: true, + }, + ], + }, + { + ...baseNavGroups[3], + items: [ + { + title: "navigation.config", + url: "/config", + icon: IconSettings, + translateTitle: true, + }, + { + title: "navigation.logs", + url: "/logs", + icon: IconListDetails, + translateTitle: true, + }, + ], + }, + ] + }, [channelItems]) + + return ( + + + {navGroups.map((group) => ( + + + + + {t(group.label)} + + + + + + + {group.items.map((item) => { + const isActive = + currentPath === item.url || + (item.url !== "/" && + currentPath.startsWith(`${item.url}/`)) + return ( + + + + + + {item.translateTitle === false + ? item.title + : t(item.title)} + + + + + ) + })} + {group.isChannelsGroup && hasMoreChannels && ( + + + {showAllChannels ? ( + + ) : ( + + )} + + {showAllChannels + ? t("navigation.show_less_channels") + : t("navigation.show_more_channels")} + + + + )} + + + + + + ))} + + + + ) +} diff --git a/web/frontend/src/components/channels/channel-config-page.tsx b/web/frontend/src/components/channels/channel-config-page.tsx new file mode 100644 index 000000000..b19d11e6a --- /dev/null +++ b/web/frontend/src/components/channels/channel-config-page.tsx @@ -0,0 +1,539 @@ +import { IconLoader2 } from "@tabler/icons-react" +import { useAtomValue } from "jotai" +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { + type ChannelConfig, + type SupportedChannel, + getAppConfig, + getChannelsCatalog, + patchAppConfig, +} from "@/api/channels" +import { getChannelDisplayName } from "@/components/channels/channel-display-name" +import { DiscordForm } from "@/components/channels/channel-forms/discord-form" +import { FeishuForm } from "@/components/channels/channel-forms/feishu-form" +import { GenericForm } from "@/components/channels/channel-forms/generic-form" +import { SlackForm } from "@/components/channels/channel-forms/slack-form" +import { TelegramForm } from "@/components/channels/channel-forms/telegram-form" +import { PageHeader } from "@/components/page-header" +import { Button } from "@/components/ui/button" +import { Switch } from "@/components/ui/switch" +import { gatewayAtom } from "@/store/gateway" + +interface ChannelConfigPageProps { + channelName: string +} + +const SECRET_FIELD_MAP: Record = { + token: "_token", + app_secret: "_app_secret", + client_secret: "_client_secret", + corp_secret: "_corp_secret", + channel_secret: "_channel_secret", + channel_access_token: "_channel_access_token", + access_token: "_access_token", + bot_token: "_bot_token", + app_token: "_app_token", + encoding_aes_key: "_encoding_aes_key", + encrypt_key: "_encrypt_key", + verification_token: "_verification_token", + password: "_password", + nickserv_password: "_nickserv_password", + sasl_password: "_sasl_password", +} + +function asRecord(value: unknown): Record { + if (value && typeof value === "object" && !Array.isArray(value)) { + return value as Record + } + return {} +} + +function asString(value: unknown): string { + return typeof value === "string" ? value : "" +} + +function asBool(value: unknown): boolean { + return value === true +} + +function buildEditConfig(config: ChannelConfig): ChannelConfig { + const edit: ChannelConfig = { ...config } + for (const secretKey of Object.keys(SECRET_FIELD_MAP)) { + if (secretKey in config) { + edit[SECRET_FIELD_MAP[secretKey]] = "" + } + } + return edit +} + +function normalizeConfig( + channel: SupportedChannel, + rawConfig: ChannelConfig, +): ChannelConfig { + const config = { ...rawConfig } + if (channel.name === "whatsapp_native") { + config.use_native = true + } + if (channel.name === "whatsapp") { + config.use_native = false + } + return config +} + +function buildSavePayload( + channel: SupportedChannel, + editConfig: ChannelConfig, + enabled: boolean, +): ChannelConfig { + const payload: ChannelConfig = { enabled } + + for (const [key, value] of Object.entries(editConfig)) { + if (key.startsWith("_")) continue + if (key === "enabled") continue + + if (key in SECRET_FIELD_MAP) { + const editKey = SECRET_FIELD_MAP[key] + const incoming = asString(editConfig[editKey]) + payload[key] = incoming !== "" ? incoming : value + continue + } + + payload[key] = value + } + + if (channel.name === "whatsapp_native") { + payload.use_native = true + } + if (channel.name === "whatsapp") { + payload.use_native = false + } + + return payload +} + +function isConfigured( + channel: SupportedChannel, + config: ChannelConfig, +): boolean { + switch (channel.name) { + case "telegram": + return asString(config.token) !== "" + case "discord": + return asString(config.token) !== "" + case "slack": + return asString(config.bot_token) !== "" + case "feishu": + return ( + asString(config.app_id) !== "" && asString(config.app_secret) !== "" + ) + case "dingtalk": + return ( + asString(config.client_id) !== "" && + asString(config.client_secret) !== "" + ) + case "line": + return asString(config.channel_access_token) !== "" + case "qq": + return ( + asString(config.app_id) !== "" && asString(config.app_secret) !== "" + ) + case "onebot": + return asString(config.ws_url) !== "" + case "wecom": + return asString(config.token) !== "" + case "wecom_app": + return ( + asString(config.corp_id) !== "" && asString(config.corp_secret) !== "" + ) + case "wecom_aibot": + return asString(config.token) !== "" + case "whatsapp": + return asString(config.bridge_url) !== "" + case "whatsapp_native": + return asBool(config.use_native) + case "pico": + return asString(config.token) !== "" + case "maixcam": + return asString(config.host) !== "" + case "matrix": + return ( + asString(config.homeserver) !== "" && + asString(config.user_id) !== "" && + asString(config.access_token) !== "" + ) + case "irc": + return asString(config.server) !== "" + default: + return false + } +} + +function getRequiredFieldKeys(channelName: string): string[] { + switch (channelName) { + case "telegram": + return ["token"] + case "discord": + return ["token"] + case "slack": + return ["bot_token"] + case "feishu": + return ["app_id", "app_secret"] + case "dingtalk": + return ["client_id", "client_secret"] + case "line": + return ["channel_secret", "channel_access_token"] + case "qq": + return ["app_id", "app_secret"] + case "onebot": + return ["ws_url"] + case "wecom": + return ["token"] + case "wecom_app": + return ["corp_id", "corp_secret"] + case "wecom_aibot": + return ["token"] + case "whatsapp": + return ["bridge_url"] + case "pico": + return ["token"] + case "maixcam": + return ["host"] + case "matrix": + return ["homeserver", "user_id", "access_token"] + case "irc": + return ["server"] + default: + return [] + } +} + +function isMissingRequiredValue(value: unknown): boolean { + if (value === null || value === undefined) { + return true + } + if (typeof value === "string") { + return value.trim() === "" + } + if (Array.isArray(value)) { + return value.length === 0 + } + return false +} + +function getChannelDocSlug(channelName: string): string { + return channelName.replaceAll("_", "-") +} + +const CHANNELS_WITHOUT_DOCS = new Set([ + "pico", + "wecom", + "matrix", + "irc", + "whatsapp", + "whatsapp_native", +]) + +export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { + const { t, i18n } = useTranslation() + const gateway = useAtomValue(gatewayAtom) + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [fetchError, setFetchError] = useState("") + const [serverError, setServerError] = useState("") + const [fieldErrors, setFieldErrors] = useState>({}) + + const [channel, setChannel] = useState(null) + const [baseConfig, setBaseConfig] = useState({}) + const [editConfig, setEditConfig] = useState({}) + const [enabled, setEnabled] = useState(false) + + const loadData = useCallback(async () => { + setLoading(true) + try { + const [catalog, appConfig] = await Promise.all([ + getChannelsCatalog(), + getAppConfig(), + ]) + const matched = + catalog.channels.find((item) => item.name === channelName) ?? null + + if (!matched) { + setChannel(null) + setFetchError( + t("channels.page.notFound", { + name: channelName, + }), + ) + return + } + + const channelsConfig = asRecord(asRecord(appConfig).channels) + const raw = asRecord(channelsConfig[matched.config_key]) + const normalized = normalizeConfig(matched, raw) + + setChannel(matched) + setBaseConfig(normalized) + setEditConfig(buildEditConfig(normalized)) + setEnabled(asBool(normalized.enabled)) + setFetchError("") + setServerError("") + setFieldErrors({}) + } catch (e) { + setFetchError(e instanceof Error ? e.message : t("channels.loadError")) + } finally { + setLoading(false) + } + }, [channelName, t]) + + useEffect(() => { + loadData() + }, [loadData]) + + const previousGatewayStatusRef = useRef(gateway.status) + useEffect(() => { + const previousStatus = previousGatewayStatusRef.current + if (previousStatus !== "running" && gateway.status === "running") { + void loadData() + } + previousGatewayStatusRef.current = gateway.status + }, [gateway.status, loadData]) + + const savePayload = useMemo(() => { + if (!channel) return null + return buildSavePayload(channel, editConfig, enabled) + }, [channel, editConfig, enabled]) + + const configured = useMemo(() => { + if (!channel || !savePayload) return false + return isConfigured(channel, savePayload) + }, [channel, savePayload]) + + const docsUrl = useMemo(() => { + if (!channel) return "" + if (CHANNELS_WITHOUT_DOCS.has(channel.name)) return "" + const language = ( + i18n.resolvedLanguage ?? + i18n.language ?? + "" + ).toLowerCase() + const base = language.startsWith("zh") + ? "https://docs.picoclaw.io/zh-Hans/docs/channels" + : "https://docs.picoclaw.io/docs/channels" + return `${base}/${getChannelDocSlug(channel.name)}` + }, [channel, i18n.language, i18n.resolvedLanguage]) + + const channelDisplayName = useMemo(() => { + if (!channel) return channelName + return getChannelDisplayName(channel, t) + }, [channel, channelName, t]) + + const hiddenKeys = useMemo(() => { + if (!channel) return [] + if (channel.name === "whatsapp") { + return ["use_native"] + } + if (channel.name === "whatsapp_native") { + return ["use_native", "bridge_url"] + } + return [] + }, [channel]) + const requiredKeys = useMemo( + () => getRequiredFieldKeys(channelName), + [channelName], + ) + + const handleChange = useCallback((key: string, value: unknown) => { + const normalizedKey = key.startsWith("_") ? key.slice(1) : key + setEditConfig((prev) => ({ ...prev, [key]: value })) + setFieldErrors((prev) => { + if (!(key in prev) && !(normalizedKey in prev)) { + return prev + } + const next = { ...prev } + delete next[key] + delete next[normalizedKey] + return next + }) + }, []) + + const handleReset = () => { + setEditConfig(buildEditConfig(baseConfig)) + setEnabled(asBool(baseConfig.enabled)) + setServerError("") + setFieldErrors({}) + } + + const handleSave = async () => { + if (!channel || !savePayload) return + + const missingRequiredFields = requiredKeys.filter((key) => + isMissingRequiredValue(savePayload[key]), + ) + if (missingRequiredFields.length > 0) { + const requiredFieldError = t("channels.validation.requiredField") + const nextFieldErrors: Record = {} + for (const key of missingRequiredFields) { + nextFieldErrors[key] = requiredFieldError + } + setFieldErrors(nextFieldErrors) + setServerError("") + return + } + + setSaving(true) + setServerError("") + setFieldErrors({}) + try { + await patchAppConfig({ + channels: { + [channel.config_key]: savePayload, + }, + }) + toast.success(t("channels.page.saveSuccess")) + await loadData() + } catch (e) { + const message = + e instanceof Error ? e.message : t("channels.page.saveError") + setServerError(message) + toast.error(message) + } finally { + setSaving(false) + } + } + + const renderForm = () => { + if (!channel) return null + const isEdit = configured + + switch (channel.name) { + case "telegram": + return ( + + ) + case "discord": + return ( + + ) + case "slack": + return ( + + ) + case "feishu": + return ( + + ) + default: + return ( + + ) + } + } + + return ( +
+ + {enabled ? ( + + {t("channels.page.enabled")} + + ) : configured ? ( + + {t("channels.status.configured")} + + ) : null} +
+ ) : undefined + } + /> + +
+ {loading ? ( +
+ +
+ ) : fetchError ? ( +
+ {fetchError} +
+ ) : ( +
+
+

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

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

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

+ +
+ + {renderForm()} + + {serverError && ( +

{serverError}

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

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

+

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

+ +
+ ) + } + + if (!defaultModelName) { + return ( +
+
+ +
+

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

+

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

+
+ ) + } + + if (!isConnected) { + return ( +
+
+ +
+

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

+

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

+
+ ) + } + + return ( +
+
+ +
+

{t("chat.welcome")}

+

+ {t("chat.welcomeDesc")} +

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

+ {thinkingSteps[stepIndex]} +

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