Merge branch 'main' into fix/default-model-list-bug

This commit is contained in:
理查德 2026-03-15 10:00:15 +08:00 committed by GitHub
commit 684e8fcb72
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
325 changed files with 44104 additions and 5913 deletions

View file

@ -5,10 +5,15 @@
# ANTHROPIC_API_KEY=sk-ant-xxx # ANTHROPIC_API_KEY=sk-ant-xxx
# OPENAI_API_KEY=sk-xxx # OPENAI_API_KEY=sk-xxx
# GEMINI_API_KEY=xxx # GEMINI_API_KEY=xxx
# MODELSCOPE_API_KEY=xxx
# CLAUDE_CODE_OAUTH=xxx # CLAUDE_CODE_OAUTH=xxx
# ── Chat Channel ────────────────────────── # ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC... # TELEGRAM_BOT_TOKEN=123456:ABC...
# DISCORD_BOT_TOKEN=xxx # DISCORD_BOT_TOKEN=xxx
# Feishu (飞书)
# PICOCLAW_CHANNELS_FEISHU_APP_ID=cli_xxx
# PICOCLAW_CHANNELS_FEISHU_APP_SECRET=xxx
# PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI=Typing,OneSecond
# ── Web Search (optional) ──────────────── # ── Web Search (optional) ────────────────
# BRAVE_SEARCH_API_KEY=BSA... # BRAVE_SEARCH_API_KEY=BSA...

138
.github/workflows/nightly.yml vendored Normal file
View file

@ -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 <<EOF
Nightly build for **${VERSION}**
This is an automated build and may be unstable. Use with caution.
${CHANGELOG}
EOF
)
# Delete existing nightly release and tag
gh release delete nightly --cleanup-tag -y 2>/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[@]}"

View file

@ -65,6 +65,14 @@ jobs:
with: with:
go-version-file: go.mod 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 - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@ -96,6 +104,11 @@ jobs:
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }} 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 - name: Apply release flags
shell: bash shell: bash

6
.gitignore vendored
View file

@ -47,6 +47,12 @@ docs/plans/
# Added by goreleaser init: # Added by goreleaser init:
dist/ dist/
*.vite/
# Windows Application Icon/Resource # Windows Application Icon/Resource
*.syso *.syso
# Keep embedded backend dist directory placeholder in VCS
!web/backend/dist/
web/backend/dist/*
!web/backend/dist/.gitkeep

View file

@ -6,8 +6,9 @@ before:
hooks: hooks:
- go mod tidy - go mod tidy
- go generate ./... - go generate ./...
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
- go install github.com/tc-hib/go-winres@latest - 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: builds:
- id: picoclaw - id: picoclaw
@ -17,28 +18,39 @@ builds:
- stdjson - stdjson
ldflags: ldflags:
- -s -w - -s -w
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }} - -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }} - -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }} - -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }} - -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
goos: goos:
- linux - linux
- windows - windows
- darwin - darwin
- freebsd - freebsd
- netbsd
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
- riscv64 - riscv64
- loong64 - loong64
- arm - arm
- s390x
- mipsle
goarm: goarm:
- "6" - "6"
- "7" - "7"
gomips:
- softfloat
main: ./cmd/picoclaw main: ./cmd/picoclaw
ignore: ignore:
- goos: windows - goos: windows
goarch: arm goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
- id: picoclaw-launcher - id: picoclaw-launcher
binary: picoclaw-launcher binary: picoclaw-launcher
@ -53,19 +65,30 @@ builds:
- windows - windows
- darwin - darwin
- freebsd - freebsd
- netbsd
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
- riscv64 - riscv64
- loong64 - loong64
- arm - arm
- s390x
- mipsle
goarm: goarm:
- "6" - "6"
- "7" - "7"
main: ./cmd/picoclaw-launcher gomips:
- softfloat
main: ./web/backend
ignore: ignore:
- goos: windows - goos: windows
goarch: arm goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
- id: picoclaw-launcher-tui - id: picoclaw-launcher-tui
binary: picoclaw-launcher-tui binary: picoclaw-launcher-tui
@ -80,19 +103,30 @@ builds:
- windows - windows
- darwin - darwin
- freebsd - freebsd
- netbsd
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
- riscv64 - riscv64
- loong64 - loong64
- arm - arm
- s390x
- mipsle
goarm: goarm:
- "6" - "6"
- "7" - "7"
gomips:
- softfloat
main: ./cmd/picoclaw-launcher-tui main: ./cmd/picoclaw-launcher-tui
ignore: ignore:
- goos: windows - goos: windows
goarch: arm goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
dockers_v2: dockers_v2:
- id: picoclaw - id: picoclaw
@ -103,15 +137,49 @@ dockers_v2:
- picoclaw - picoclaw
images: images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw" - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}" - 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
tags: tags:
- "{{ .Tag }}" - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
- "latest" - '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
platforms: platforms:
- linux/amd64 - linux/amd64
- linux/arm64 - linux/arm64
- linux/riscv64 - 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: archives:
- formats: [tar.gz] - formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`. # this name template makes the OS and Arch compatible with the results of `uname`.
@ -129,7 +197,7 @@ archives:
nfpms: nfpms:
- id: picoclaw - id: picoclaw
builds: ids:
- picoclaw - picoclaw
- picoclaw-launcher - picoclaw-launcher
- picoclaw-launcher-tui - picoclaw-launcher-tui
@ -149,6 +217,11 @@ nfpms:
- rpm - rpm
- deb - deb
bindir: /usr/bin 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: changelog:
sort: asc sort: asc
@ -163,6 +236,7 @@ changelog:
# lzma: true # lzma: true
release: release:
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
footer: >- footer: >-
--- ---

View file

@ -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") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z) BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}') GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w" 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 variables
GO?=CGO_ENABLED=0 go GO?=CGO_ENABLED=0 go
@ -111,6 +111,18 @@ build: generate
@echo "Build complete: $(BINARY_PATH)" @echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
## build-launcher: Build the picoclaw-launcher (web console) binary
build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@if [ ! -f web/backend/dist/index.html ]; then \
echo "Building frontend..."; \
cd web/frontend && pnpm install && pnpm build:backend; \
fi
@$(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: Build with WhatsApp native (whatsmeow) support; larger binary
build-whatsapp-native: generate build-whatsapp-native: generate
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..." ## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
@ -169,6 +181,8 @@ build-all: generate
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR) GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
GOOS=netbsd GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
GOOS=netbsd GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
@echo "All builds complete" @echo "All builds complete"
## install: Install picoclaw to system and copy builtin skills ## install: Install picoclaw to system and copy builtin skills

View file

@ -1,17 +1,21 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1> <h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
<h3>Matériel à 10$ · 10 Mo de RAM · Démarrage en 1s · 皮皮虾,我们走!</h3> <h3>Matériel à 10$ · 10 Mo de RAM · Démarrage en 1s · 皮皮虾,我们走!</h3>
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a> <a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a> <a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p> </p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français** [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md) | **Français**
@ -206,9 +210,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Démarrage Rapide ### 🚀 Démarrage Rapide
> [!TIP] > [!TIP]
> Configurez votre clé API dans `~/.picoclaw/config.json`. > Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement 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).
> 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é.
**1. Initialiser** **1. Initialiser**
@ -222,8 +224,14 @@ picoclaw onboard
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key",
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300, "request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
@ -231,7 +239,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model_name": "gpt4" "model_name": "gpt-5.4"
} }
}, },
"channels": { "channels": {
@ -649,7 +657,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) ├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
├── IDENTITY.md # Identité de l'Agent ├── IDENTITY.md # Identité de l'Agent
├── SOUL.md # Âme de l'Agent ├── SOUL.md # Âme de l'Agent
├── TOOLS.md # Description des outils
└── USER.md # Préférences utilisateur └── USER.md # Préférences utilisateur
``` ```
@ -833,6 +840,7 @@ Le sous-agent a accès aux outils (message, web_search, etc.) et peut communique
| ------------------------ | ---------------------------------------- | ------------------------------------------------------ | | ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) | | `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
| `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) | | `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
@ -978,8 +986,12 @@ 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) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) | | **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://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://www.byteplus.com/) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obtenir un Token](https://modelscope.cn/my/tokens) |
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Obtenir Clé](https://portal.azure.com) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -989,8 +1001,13 @@ Cette conception permet également le **support multi-agent** avec une sélectio
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "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" "api_key": "sk-your-openai-key"
}, },
{ {
@ -1006,7 +1023,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -1017,8 +1034,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
**OpenAI** **OpenAI**
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1061,14 +1087,14 @@ Configurez plusieurs points de terminaison pour le même nom de modèle—PicoCl
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1200,6 +1226,14 @@ Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assu
| Service | Offre Gratuite | Cas d'Utilisation | | Service | Offre Gratuite | Cas d'Utilisation |
| ---------------- | -------------------- | ------------------------------------- | | ---------------- | -------------------- | ------------------------------------- |
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) | | **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 | | **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) | | **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
| **ModelScope** | 2000 requêtes/jour | Inférence gratuite (Qwen, GLM, DeepSeek, etc.) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,16 +1,23 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1> <h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
<h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!</h3> <h3>$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!</h3>
<h3></h3> <h3></h3>
<p>
<p> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <br>
</p> <a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) [中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
@ -168,9 +175,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 クイックスタート(ネイティブ) ### 🚀 クイックスタート(ネイティブ)
> [!TIP] > [!TIP]
> `~/.picoclaw/config.json` に API キーを設定してください。 > `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
> 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 クエリ無料)
**1. 初期化** **1. 初期化**
@ -184,8 +189,14 @@ picoclaw onboard
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key",
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300, "request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
@ -193,7 +204,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model_name": "gpt4" "model_name": "gpt-5.4"
} }
}, },
"channels": { "channels": {
@ -610,7 +621,6 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
├── HEARTBEAT.md # 定期タスクプロンプト30分ごとに確認 ├── HEARTBEAT.md # 定期タスクプロンプト30分ごとに確認
├── IDENTITY.md # エージェントのアイデンティティ ├── IDENTITY.md # エージェントのアイデンティティ
├── SOUL.md # エージェントのソウル ├── SOUL.md # エージェントのソウル
├── TOOLS.md # ツールの説明
└── USER.md # ユーザー設定 └── USER.md # ユーザー設定
``` ```
@ -791,6 +801,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| --- | --- | --- | | --- | --- | --- |
| `gemini` | LLMGemini 直接) | [aistudio.google.com](https://aistudio.google.com) | | `gemini` | LLMGemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLMZhipu 直接) | [bigmodel.cn](https://bigmodel.cn) | | `zhipu` | LLMZhipu 直接) | [bigmodel.cn](https://bigmodel.cn) |
| `volcengine` | LLM(Volcengine 直接) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter`(要テスト) | LLM推奨、全モデルにアクセス可能 | [openrouter.ai](https://openrouter.ai) | | `openrouter`(要テスト) | LLM推奨、全モデルにアクセス可能 | [openrouter.ai](https://openrouter.ai) |
| `anthropic`(要テスト) | LLMClaude 直接) | [console.anthropic.com](https://console.anthropic.com) | | `anthropic`(要テスト) | LLMClaude 直接) | [console.anthropic.com](https://console.anthropic.com) |
| `openai`(要テスト) | LLMGPT 直接) | [platform.openai.com](https://platform.openai.com) | | `openai`(要テスト) | LLMGPT 直接) | [platform.openai.com](https://platform.openai.com) |
@ -919,8 +930,12 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) | | **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://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://www.byteplus.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [トークンを取得](https://modelscope.cn/my/tokens) |
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [キーを取得](https://portal.azure.com) |
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ | | **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -930,8 +945,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "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" "api_key": "sk-your-openai-key"
}, },
{ {
@ -947,7 +967,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -958,8 +978,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
**OpenAI** **OpenAI**
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1002,14 +1031,14 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1120,9 +1149,17 @@ Web 検索を有効にするには:
| サービス | 無料枠 | ユースケース | | サービス | 無料枠 | ユースケース |
|---------|--------|------------| |---------|--------|------------|
| **OpenRouter** | 月 200K トークン | 複数モデルClaude, GPT-4 など) | | **OpenRouter** | 月 200K トークン | 複数モデルClaude, GPT-4 など) |
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 | | **Volcengine CodingPlan** | 9.9元/初月 | 中国ユーザーに最適、複数のSOTAモデルDoubao、DeepSeek等 |
| **Zhipu** | 月 200K トークン | 中国ユーザーに適している |
| **Qwen** | 無料枠あり | 通義千問 (Qwen) | | **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 | | **Brave Search** | 月 2000 クエリ | Web 検索機能 |
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 | | **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
| **Groq** | 無料枠あり | 高速推論Llama, Mixtral | | **Groq** | 無料枠あり | 高速推論Llama, Mixtral |
| **Cerebras** | 無料枠あり | 高速推論Llama, Qwen など) | | **Cerebras** | 無料枠あり | 高速推論Llama, Qwen など) |
| **ModelScope** | 1 日 2000 リクエスト | 無料推論Qwen, GLM, DeepSeek など) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

172
README.md
View file

@ -1,18 +1,19 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1> <h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
<h3>$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!</h3> <h3>$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!</h3>
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a> <a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a> <a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br> <br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a> <a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a> <a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p> </p>
@ -56,7 +57,7 @@
2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we cant wait to have you on board! 2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](ROADMAP.md) —we cant wait to have you on board!
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. 2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs & issues coming in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. 🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClawLet's Go 2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClawLet's Go
@ -194,6 +195,19 @@ docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
docker compose -f docker/docker-compose.yml --profile gateway down 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) ### Agent Mode (One-shot)
```bash ```bash
@ -214,9 +228,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Quick Start ### 🚀 Quick Start
> [!TIP] > [!TIP]
> Set your API key in `~/.picoclaw/config.json`. > Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
> 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.
**1. Initialize** **1. Initialize**
@ -231,7 +243,7 @@ picoclaw onboard
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"model_name": "gpt4", "model_name": "gpt-5.4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20
@ -239,8 +251,14 @@ picoclaw onboard
}, },
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key",
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key", "api_key": "your-api-key",
"request_timeout": 300 "request_timeout": 300
}, },
@ -308,7 +326,7 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps ## 💬 Chat Apps
Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom Talk to your picoclaw through Telegram, Discord, WhatsApp, Matrix, QQ, DingTalk, LINE, or WeCom
> **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server. > **Note**: All webhook-based channels (LINE, WeCom, etc.) are served on a single shared Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). There are no per-channel ports to configure. Note: Feishu uses WebSocket/SDK mode and does not use the shared HTTP webhook server.
@ -317,6 +335,7 @@ Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or We
| **Telegram** | Easy (just a token) | | **Telegram** | Easy (just a token) |
| **Discord** | Easy (bot token + intents) | | **Discord** | Easy (bot token + intents) |
| **WhatsApp** | Easy (native: QR scan; or bridge URL) | | **WhatsApp** | Easy (native: QR scan; or bridge URL) |
| **Matrix** | Medium (homeserver + bot access token) |
| **QQ** | Easy (AppID + AppSecret) | | **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) | | **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) | | **LINE** | Medium (credentials + webhook URL) |
@ -528,6 +547,40 @@ picoclaw gateway
``` ```
</details> </details>
<details>
<summary><b>Matrix</b></summary>
**1. Prepare bot account**
* Use your preferred homeserver (e.g. `https://matrix.org` or self-hosted)
* Create a bot user and obtain its access token
**2. Configure**
```json
{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
"allow_from": []
}
}
}
```
**3. Run**
```bash
picoclaw gateway
```
For full options (`device_id`, `join_on_invite`, `group_trigger`, `placeholder`, `reasoning_channel_id`), see [Matrix Channel Configuration Guide](docs/channels/matrix/README.md).
</details>
<details> <details>
<summary><b>LINE</b></summary> <summary><b>LINE</b></summary>
@ -739,7 +792,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) ├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
├── IDENTITY.md # Agent identity ├── IDENTITY.md # Agent identity
├── SOUL.md # Agent soul ├── SOUL.md # Agent soul
├── TOOLS.md # Tool descriptions
└── USER.md # User preferences └── USER.md # User preferences
``` ```
@ -941,18 +993,20 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
> [!NOTE] > [!NOTE]
> Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level. > Groq provides free voice transcription via Whisper. If configured, audio messages from any channel will be automatically transcribed at the agent level.
| Provider | Purpose | Get API Key | | Provider | Purpose | Get API Key |
| -------------------------- | --------------------------------------- | -------------------------------------------------------------------- | | ------------ | --------------------------------------- | ------------------------------------------------------------ |
| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | | `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) | | `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](https://bigmodel.cn) |
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `volcengine` | LLM(Volcengine direct) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) | | `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
| `vivgrid` | LLM (Vivgrid direct) | [vivgrid.com](https://vivgrid.com) |
| `azure` | LLM (Azure OpenAI) | [portal.azure.com](https://portal.azure.com) |
### Model Configuration (model_list) ### Model Configuration (model_list)
@ -983,9 +1037,13 @@ This design also enables **multi-agent support** with flexible provider selectio
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key | | **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) | | **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://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://www.byteplus.com) |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) | | **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Get Token](https://modelscope.cn/my/tokens) |
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Get Key](https://portal.azure.com) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only | | **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -995,8 +1053,13 @@ This design also enables **multi-agent support** with flexible provider selectio
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "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" "api_key": "sk-your-openai-key"
}, },
{ {
@ -1012,7 +1075,7 @@ This design also enables **multi-agent support** with flexible provider selectio
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -1024,8 +1087,18 @@ This design also enables **multi-agent support** with flexible provider selectio
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1062,6 +1135,26 @@ This design also enables **multi-agent support** with flexible provider selectio
> Run `picoclaw auth login --provider anthropic` to paste your API token. > Run `picoclaw auth login --provider anthropic` to paste your API token.
**Anthropic Messages API (native format)**
For direct Anthropic API access or custom endpoints that only support Anthropic's native message format:
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"api_key": "sk-ant-your-key",
"api_base": "https://api.anthropic.com"
}
```
> Use `anthropic-messages` protocol when:
> - Using third-party proxies that only support Anthropic's native `/v1/messages` endpoint (not OpenAI-compatible `/v1/chat/completions`)
> - Connecting to services like MiniMax, Synthetic that require Anthropic's native message format
> - The existing `anthropic` protocol returns 404 errors (indicating the endpoint doesn't support OpenAI-compatible format)
>
> **Note:** The `anthropic` protocol uses OpenAI-compatible format (`/v1/chat/completions`), while `anthropic-messages` uses Anthropic's native format (`/v1/messages`). Choose based on your endpoint's supported format.
**Ollama (local)** **Ollama (local)**
```json ```json
@ -1104,14 +1197,14 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1451,8 +1544,17 @@ This happens when another instance of the bot is running. Make sure only one `pi
| Service | Free Tier | Use Case | | Service | Free Tier | Use Case |
| ---------------- | ------------------------ | ------------------------------------- | | ---------------- | ------------------------ | ------------------------------------- |
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | | **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 | | **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) | | **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | | **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) | | **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
| **ModelScope** | 2000 requests/day | Free inference (Qwen, GLM, DeepSeek, etc.) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,17 +1,21 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1> <h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
<h3>Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!</h3> <h3>Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!</h3>
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a> <a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a> <a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p> </p>
[中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) [中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
@ -207,9 +211,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Início Rápido ### 🚀 Início Rápido
> [!TIP] > [!TIP]
> Configure sua API key em `~/.picoclaw/config.json`. > Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). 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).
> 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.
**1. Inicializar** **1. Inicializar**
@ -223,8 +225,14 @@ picoclaw onboard
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key",
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300, "request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
@ -232,7 +240,7 @@ picoclaw onboard
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model_name": "gpt4" "model_name": "gpt-5.4"
} }
}, },
"tools": { "tools": {
@ -645,7 +653,6 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) ├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
├── IDENTITY.md # Identidade do Agente ├── IDENTITY.md # Identidade do Agente
├── SOUL.md # Alma do Agente ├── SOUL.md # Alma do Agente
├── TOOLS.md # Descrição das ferramentas
└── USER.md # Preferencias do usuario └── USER.md # Preferencias do usuario
``` ```
@ -829,6 +836,7 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
| --- | --- | --- | | --- | --- | --- |
| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) | | `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) | | `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) |
| `volcengine` | LLM(Volcengine direto) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) | | `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) | | `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) | | `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
@ -974,8 +982,12 @@ 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) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) | | **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://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://www.byteplus.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Obter Token](https://modelscope.cn/my/tokens) |
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Obter Chave](https://portal.azure.com) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth | | **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -985,8 +997,13 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "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" "api_key": "sk-your-openai-key"
}, },
{ {
@ -1002,7 +1019,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -1013,8 +1030,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
**OpenAI** **OpenAI**
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1057,14 +1083,14 @@ Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-r
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1196,7 +1222,15 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
| Serviço | Plano Gratuito | Caso de Uso | | Serviço | Plano Gratuito | Caso de Uso |
| --- | --- | --- | | --- | --- | --- |
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) | | **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 | | **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) | | **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) | | **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
| **ModelScope** | 2000 requisições/dia | Inferência gratuita (Qwen, GLM, DeepSeek, etc.) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,17 +1,21 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1> <h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
<h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · Nào, xuất phát!</h3> <h3>Phần cứng $10 · RAM 10MB · Khởi động 1 giây · Nào, xuất phát!</h3>
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a> <a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a> <a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p> </p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md) [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
@ -52,7 +56,7 @@
2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn! 2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw. 2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần. 🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường! 2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
@ -187,9 +191,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Bắt đầu nhanh ### 🚀 Bắt đầu nhanh
> [!TIP] > [!TIP]
> Thiết lập API key trong `~/.picoclaw/config.json`. > Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [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).
> 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.
**1. Khởi tạo** **1. Khởi tạo**
@ -203,8 +205,14 @@ picoclaw onboard
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key",
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"request_timeout": 300, "request_timeout": 300,
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
@ -617,7 +625,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) ├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
├── IDENTITY.md # Danh tính Agent ├── IDENTITY.md # Danh tính Agent
├── SOUL.md # Tâm hồn/Tính cách 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 └── USER.md # Tùy chọn người dùng
``` ```
@ -801,6 +808,7 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
| --- | --- | --- | | --- | --- | --- |
| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) | | `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) | | `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) |
| `volcengine` | LLM(Volcengine trực tiếp) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) | | `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) | | `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) | | `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
@ -943,8 +951,12 @@ 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) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) | | **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://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **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://www.byteplus.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [Lấy Token](https://modelscope.cn/my/tokens) |
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [Lấy Khóa](https://portal.azure.com) |
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth | | **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -954,8 +966,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_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "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" "api_key": "sk-your-openai-key"
}, },
{ {
@ -971,7 +988,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -982,8 +999,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
**OpenAI** **OpenAI**
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -1026,14 +1052,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_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -1165,6 +1191,14 @@ 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 | | 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.) | | **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 | | **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) | | **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
| **ModelScope** | 2000 yêu cầu/ngày | Suy luận miễn phí (Qwen, GLM, DeepSeek, v.v.) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,17 +1,21 @@
<div align="center"> <div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512"> <img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1> <h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
<h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3> <h3>10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!</h3>
<p> <p>
<img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go"> <img src="https://img.shields.io/badge/Go-1.21+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware"> <img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br> <br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a> <a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a> <a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p> </p>
**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md) **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
@ -117,7 +121,7 @@ pkg install proot
termux-chroot ./picoclaw-linux-arm64 onboard termux-chroot ./picoclaw-linux-arm64 onboard
``` ```
然后跟随下面的“快速开始”章节继续配置picoclaw即可使用 然后跟随下面的“快速开始”章节继续配置picoclaw即可使用
<img src="assets/termux.jpg" alt="PicoClaw" width="512"> <img src="assets/termux.jpg" alt="PicoClaw" width="512">
### 🐜 创新的低占用部署 ### 🐜 创新的低占用部署
@ -208,9 +212,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 快速开始 ### 🚀 快速开始
> [!TIP] > [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。 > 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
> 获取 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 次免费查询)
**1. 初始化 (Initialize)** **1. 初始化 (Initialize)**
@ -226,7 +228,7 @@ picoclaw onboard
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"model_name": "gpt4", "model_name": "gpt-5.4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20 "max_tool_iterations": 20
@ -234,8 +236,14 @@ picoclaw onboard
}, },
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key",
"api_base":"https://ark.cn-beijing.volces.com/api/coding/v3"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key", "api_key": "your-api-key",
"request_timeout": 300 "request_timeout": 300
}, },
@ -299,6 +307,7 @@ PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方
| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) | | **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
| **Discord** | ⭐ 简单 | Socket Mode支持群组/私信Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) | | **Discord** | ⭐ 简单 | Socket Mode支持群组/私信Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) | | **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
| **Matrix** | ⭐⭐ 中等 | 联邦协议,支持自建 homeserver 与公开服务器 | [查看文档](docs/channels/matrix/README.zh.md) |
| **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) | | **QQ** | ⭐⭐ 中等 | 官方机器人 API适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) | | **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) | | **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)、自建应用(API)和智能机器人(AI Bot) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) / [AI Bot 文档](docs/channels/wecom/wecom_aibot/README.zh.md) |
@ -364,7 +373,6 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) ├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
├── IDENTITY.md # Agent 身份设定 ├── IDENTITY.md # Agent 身份设定
├── SOUL.md # Agent 灵魂/性格 ├── SOUL.md # Agent 灵魂/性格
├── TOOLS.md # 工具描述
└── USER.md # 用户偏好 └── USER.md # 用户偏好
``` ```
@ -478,10 +486,11 @@ Agent 读取 HEARTBEAT.md
| -------------------- | ---------------------------- | -------------------------------------------------------------------- | | -------------------- | ---------------------------- | -------------------------------------------------------------------- |
| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | | `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) | | `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | | `volcengine` | LLM (火山引擎直连) | [volcengine.com](https://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | | `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | | `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | | `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
| `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | | `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) | | `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
@ -514,8 +523,12 @@ Agent 读取 HEARTBEAT.md
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) | | **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 | | **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) | | **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://www.volcengine.com/activity/codingplan?utm_campaign=PicoClaw&utm_content=PicoClaw&utm_medium=devrel&utm_source=OWO&utm_term=PicoClaw) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - | | **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://www.byteplus.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
| **ModelScope (魔搭)**| `modelscope/` | `https://api-inference.modelscope.cn/v1` | OpenAI | [获取 Token](https://modelscope.cn/my/tokens) |
| **Azure OpenAI** | `azure/` | `https://{resource}.openai.azure.com` | Azure | [获取密钥](https://portal.azure.com) |
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth | | **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - | | **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -525,8 +538,13 @@ Agent 读取 HEARTBEAT.md
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "ark-code-latest",
"model": "openai/gpt-5.2", "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" "api_key": "sk-your-openai-key"
}, },
{ {
@ -542,7 +560,7 @@ Agent 读取 HEARTBEAT.md
], ],
"agents": { "agents": {
"defaults": { "defaults": {
"model": "gpt-5.2" "model": "gpt-5.4"
} }
} }
} }
@ -554,8 +572,18 @@ Agent 读取 HEARTBEAT.md
```json ```json
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**火山引擎Doubao**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..." "api_key": "sk-..."
} }
``` ```
@ -592,6 +620,26 @@ Agent 读取 HEARTBEAT.md
> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。 > 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
**Anthropic Messages API原生格式**
用于直接访问 Anthropic API 或仅支持 Anthropic 原生消息格式的自定义端点:
```json
{
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"api_key": "sk-ant-your-key",
"api_base": "https://api.anthropic.com"
}
```
> 使用 `anthropic-messages` 协议的场景:
> - 使用仅支持 Anthropic 原生 `/v1/messages` 端点的第三方代理(不支持 OpenAI 兼容的 `/v1/chat/completions`
> - 连接到 MiniMax、Synthetic 等需要 Anthropic 原生消息格式的服务
> - 现有的 `anthropic` 协议返回 404 错误(说明端点不支持 OpenAI 兼容格式)
>
> **注意:** `anthropic` 协议使用 OpenAI 兼容格式(`/v1/chat/completions`),而 `anthropic-messages` 使用 Anthropic 原生格式(`/v1/messages`)。请根据端点支持的格式选择。
**Ollama (本地)** **Ollama (本地)**
```json ```json
@ -621,14 +669,14 @@ Agent 读取 HEARTBEAT.md
{ {
"model_list": [ "model_list": [
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1", "api_base": "https://api1.example.com/v1",
"api_key": "sk-key1" "api_key": "sk-key1"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1", "api_base": "https://api2.example.com/v1",
"api_key": "sk-key2" "api_key": "sk-key2"
} }
@ -874,7 +922,16 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| 服务 | 免费层级 | 适用场景 | | 服务 | 免费层级 | 适用场景 |
| --- | --- | --- | | --- | --- | --- |
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | | **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 | | **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型豆包、DeepSeek 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 | | **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 | | **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | | **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
| **ModelScope (魔搭)** | 2000 次请求/天 | 免费推理 (Qwen, GLM, DeepSeek 等) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

BIN
assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 93 KiB

View file

@ -1,6 +1,7 @@
package ui package ui
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@ -67,6 +68,7 @@ func Run() error {
root := tview.NewFlex().SetDirection(tview.FlexRow) root := tview.NewFlex().SetDirection(tview.FlexRow)
root.AddItem(bannerView(), 6, 0, false) root.AddItem(bannerView(), 6, 0, false)
root.AddItem(state.pages, 0, 1, true) 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 { if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
return err return err
@ -102,7 +104,7 @@ func (s *appState) pop() {
} }
func (s *appState) mainMenu() tview.Primitive { func (s *appState) mainMenu() tview.Primitive {
menu := NewMenu("Config Menu", nil) menu := NewMenu("Menu", nil)
refreshMainMenu(menu, s) refreshMainMenu(menu, s)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() { switch event.Key() {
@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive {
s.requestExit() s.requestExit()
return nil return nil
} }
if event.Rune() == 'q' {
s.requestExit()
return nil
}
return event 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) { func refreshMainMenuIfPresent(s *appState) {
if menu, ok := s.menus["main"]; ok { if menu, ok := s.menus["main"]; ok {
refreshMainMenu(menu, s) refreshMainMenu(menu, s)
@ -141,6 +166,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
selectedModel := s.selectedModelName() selectedModel := s.selectedModelName()
modelReady := selectedModel != "" modelReady := selectedModel != ""
channelReady := s.hasEnabledChannel() channelReady := s.hasEnabledChannel()
enabledCount, totalChannels := s.countChannels()
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning() gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
gatewayLabel := "Start Gateway" gatewayLabel := "Start Gateway"
@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
items := []MenuItem{ items := []MenuItem{
{ {
Label: rootModelLabel(selectedModel), Label: rootModelLabel(selectedModel),
Description: rootModelDescription(selectedModel), Description: rootModelDescription(),
Action: func() { Action: func() {
s.push("model", s.modelMenu()) s.push("model", s.modelMenu())
}, },
@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
}, },
{ {
Label: rootChannelLabel(channelReady), Label: rootChannelLabel(channelReady),
Description: rootChannelDescription(channelReady), Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
Action: func() { Action: func() {
s.push("channel", s.channelMenu()) s.push("channel", s.channelMenu())
}, },
@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
func rootModelLabel(selected string) string { func rootModelLabel(selected string) string {
if selected == "" { if selected == "" {
return "Model (no model selected)" return "Model (None)"
} }
return "Model (" + selected + ")" return "Model (" + selected + ")"
} }
func rootModelDescription(selected string) string { func rootModelDescription() string {
if selected == "" { return "Using SPACE to choose your model"
return "no model selected"
}
return "selected"
} }
func rootChannelLabel(valid bool) string { func rootChannelLabel(valid bool) string {
@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
return "Channel" return "Channel"
} }
func rootChannelDescription(valid bool) string {
if !valid {
return "no channel enabled"
}
return "enabled"
}
func (s *appState) startTalk() { func (s *appState) startTalk() {
if !s.isActiveModelValid() { if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting talk") s.showMessage("Model required", "Select a valid model before starting talk")
@ -423,7 +439,7 @@ func (s *appState) hasEnabledChannel() bool {
c := s.config.Channels c := s.config.Channels
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled || return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled || c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
} }
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) { func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {

View file

@ -12,7 +12,6 @@ import (
func (s *appState) buildChannelMenuItems() []MenuItem { func (s *appState) buildChannelMenuItems() []MenuItem {
return []MenuItem{ return []MenuItem{
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
channelItem( channelItem(
"Telegram", "Telegram",
"Telegram bot settings", "Telegram bot settings",
@ -61,6 +60,12 @@ func (s *appState) buildChannelMenuItems() []MenuItem {
s.config.Channels.Slack.Enabled, s.config.Channels.Slack.Enabled,
func() { s.push("channel-slack", s.slackForm()) }, func() { s.push("channel-slack", s.slackForm()) },
), ),
channelItem(
"Matrix",
"Matrix bot settings",
s.config.Channels.Matrix.Enabled,
func() { s.push("channel-matrix", s.matrixForm()) },
),
channelItem( channelItem(
"LINE", "LINE",
"LINE bot settings", "LINE bot settings",
@ -95,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive {
s.pop() s.pop()
return nil return nil
} }
if event.Rune() == 'q' {
s.pop()
return nil
}
return event return event
}) })
return menu return menu
@ -233,6 +234,28 @@ func (s *appState) lineForm() tview.Primitive {
return wrapWithBack(form, s) return wrapWithBack(form, s)
} }
func (s *appState) matrixForm() tview.Primitive {
cfg := &s.config.Channels.Matrix
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
cfg.Homeserver = strings.TrimSpace(text)
})
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
cfg.UserID = strings.TrimSpace(text)
})
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
cfg.AccessToken = strings.TrimSpace(text)
})
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
cfg.DeviceID = strings.TrimSpace(text)
})
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
cfg.JoinOnInvite = checked
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) onebotForm() tview.Primitive { func (s *appState) onebotForm() tview.Primitive {
cfg := &s.config.Channels.OneBot cfg := &s.config.Channels.OneBot
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled)) form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))

View file

@ -14,23 +14,7 @@ import (
) )
func (s *appState) modelMenu() tview.Primitive { func (s *appState) modelMenu() tview.Primitive {
items := make([]MenuItem, 0, 2+len(s.config.ModelList)) items := make([]MenuItem, 0, 1+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),
)
},
},
)
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList { for i := range s.config.ModelList {
index := i 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 := NewMenu("Models", items)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
@ -64,14 +65,11 @@ func (s *appState) modelMenu() tview.Primitive {
s.pop() s.pop()
return nil return nil
} }
if event.Rune() == 'q' {
s.pop()
return nil
}
if event.Rune() == ' ' { if event.Rune() == ' ' {
row, _ := menu.GetSelection() row, _ := menu.GetSelection()
if row > 0 && row <= len(s.config.ModelList) { if row >= 0 && row < len(s.config.ModelList) {
model := s.config.ModelList[row-1] model := s.config.ModelList[row]
if !isModelValid(model) { if !isModelValid(model) {
s.showMessage( s.showMessage(
"Invalid model", "Invalid model",
@ -95,12 +93,23 @@ func (s *appState) modelForm(index int) tview.Primitive {
model := &s.config.ModelList[index] model := &s.config.ModelList[index]
form := tview.NewForm() form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName)) 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) { 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 model.ModelName = value
if s.config.Agents.Defaults.Model == oldName {
s.config.Agents.Defaults.Model = value
}
s.dirty = true s.dirty = true
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
refreshMainMenuIfPresent(s) refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok { if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s) refreshModelMenuFromState(menu, s)
@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive {
}) })
form.AddButton("Delete", func() { 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() { form.AddButton("Test", func() {
s.testModel(model) s.testModel(model)
@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) { func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
for i, model := range models { for i, model := range models {
row := i + 1 row := i
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model) label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
isValid := isModelValid(model) isValid := isModelValid(model)
if model.ModelName == currentModel && currentModel != "" { if model.ModelName == currentModel && currentModel != "" {
@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
} }
func refreshModelMenuFromState(menu *Menu, s *appState) { func refreshModelMenuFromState(menu *Menu, s *appState) {
items := make([]MenuItem, 0, 2+len(s.config.ModelList)) items := make([]MenuItem, 0, 1+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),
)
},
},
)
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model) currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList { for i := range s.config.ModelList {
index := i 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) menu.applyItems(items)
} }
@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool {
return hasKey && hasModel 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) { func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
if model == nil { if model == nil {
return return

View file

@ -41,3 +41,15 @@ func bannerView() *tview.TextView {
text.SetBorder(false) text.SetBorder(false)
return text 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
}

View file

@ -1,290 +0,0 @@
# PicoClaw Launcher
> [!WARNING]
> This project is a temporary solution and will be refactored in the future to provide a complete web service. Therefore, the APIs in this directory are not stable.
A standalone launcher for PicoClaw, providing visual JSON editing and OAuth provider authentication management.
## Features
- 📝 **Config Editor** — Sidebar-based settings UI with model management, channel configuration forms, and a raw JSON editor
- 🤖 **Model Management** — Model card grid with availability status (grayed out without API key), primary model selection, add/edit/delete with required/optional field separation
- 📡 **Channel Configuration** — Form-based settings for 12 channel types (Telegram, Discord, Slack, WeCom, DingTalk, Feishu, LINE, WhatsApp, QQ, OneBot, MaixCAM, etc.) with documentation links
- 🔐 **Provider Auth** — Login to OpenAI (Device Code), Anthropic (API Token), Google Antigravity (Browser OAuth)
- 🌐 **Embedded Frontend** — Compiles to a single binary with no external dependencies
- 🌍 **i18n** — Chinese/English language switching with browser auto-detection
- 🎨 **Theme** — Light / Dark / System theme toggle with localStorage persistence
## Quick Start
```bash
# Build
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
# Run with default config path (~/.picoclaw/config.json)
./picoclaw-launcher
# Specify a config file
./picoclaw-launcher ./config.json
# Allow LAN access
./picoclaw-launcher -public
```
Open `http://localhost:18800` in your browser.
## CLI Options
```
Usage: picoclaw-config [options] [config.json]
Arguments:
config.json Path to the configuration file (default: ~/.picoclaw/config.json)
Options:
-public Listen on all interfaces (0.0.0.0), allowing access from other devices
```
## API Reference
Base URL: `http://localhost:18800`
---
### Static Files
#### GET /
Serves the embedded frontend (`index.html`).
---
### Config API
#### GET /api/config
Reads the current configuration file.
**Response** `200 OK`
```json
{
"config": { ... },
"path": "/Users/xiao/.picoclaw/config.json"
}
```
---
#### PUT /api/config
Saves the configuration. The request body must be a complete Config JSON object.
**Request Body** — `application/json`
```json
{
"agents": { "defaults": { "model_name": "gpt-5.2" } },
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"auth_method": "oauth"
}
]
}
```
**Response** `200 OK`
```json
{ "status": "ok" }
```
**Error** `400 Bad Request` — Invalid JSON
---
### Auth API
#### GET /api/auth/status
Returns the authentication status of all providers and any in-progress device code login.
**Response** `200 OK`
```json
{
"providers": [
{
"provider": "openai",
"auth_method": "oauth",
"status": "active",
"account_id": "user-xxx",
"expires_at": "2026-03-01T00:00:00Z"
}
],
"pending_device": {
"provider": "openai",
"status": "pending",
"device_url": "https://auth.openai.com/activate",
"user_code": "ABCD-1234"
}
}
```
`status` values: `active` | `expired` | `needs_refresh`
`pending_device` is only present when a device code login is in progress.
---
#### POST /api/auth/login
Initiates a provider login.
**Request Body** — `application/json`
```json
{ "provider": "openai" }
```
Supported `provider` values: `openai` | `anthropic` | `google-antigravity`
##### OpenAI (Device Code Flow)
Returns device code info. The server polls for completion in the background.
```json
{
"status": "pending",
"device_url": "https://auth.openai.com/activate",
"user_code": "ABCD-1234",
"message": "Open the URL and enter the code to authenticate."
}
```
The user opens `device_url` in a browser and enters `user_code`. Once authenticated, `GET /api/auth/status` will show `pending_device.status` as `success`.
##### Anthropic (API Token)
Requires a `token` field in the request:
```json
{ "provider": "anthropic", "token": "sk-ant-xxx" }
```
**Response:**
```json
{ "status": "success", "message": "Anthropic token saved" }
```
##### Google Antigravity (Browser OAuth)
Returns an authorization URL for the frontend to open in a new tab:
```json
{
"status": "redirect",
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
"message": "Open the URL to authenticate with Google."
}
```
After authentication, Google redirects to `GET /auth/callback`, which saves the credentials and redirects back to the picoclaw-config UI.
---
#### POST /api/auth/logout
Logs out from a provider.
**Request Body** — `application/json`
```json
{ "provider": "openai" }
```
Omit or leave `provider` empty to log out from all providers.
**Response** `200 OK`
```json
{ "status": "ok" }
```
---
#### GET /auth/callback
OAuth browser callback endpoint (used by Google Antigravity). Called by the OAuth provider's redirect — **not invoked directly by the frontend**.
**Query Parameters:**
- `state` — OAuth state for CSRF validation
- `code` — Authorization code
On success, redirects to `/#auth`.
### Process API
#### GET /api/process/status
Gets the running status of the `picoclaw gateway` process.
**Response** `200 OK` (Running)
```json
{
"process_status": "running",
"status": "ok",
"uptime": "1.010814s"
}
```
**Response** `200 OK` (Stopped)
```json
{
"process_status": "stopped",
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
}
```
---
#### POST /api/process/start
Starts the `picoclaw gateway` process in the background.
**Response** `200 OK`
```json
{
"status": "ok",
"pid": 12345
}
```
---
#### POST /api/process/stop
Stops the running `picoclaw gateway` process.
**Response** `200 OK`
```json
{
"status": "ok"
}
```
---
## Testing
```bash
go test -v ./cmd/picoclaw-launcher/
```

View file

@ -1,287 +0,0 @@
# PicoClaw Launcher
> [!WARNING]
> 该项目属于临时解决方案,后续会重构并提供完整的 Web 服务,因此该目录下的接口并不稳定。
PicoClaw 的独立启动器,提供可视化 JSON 配置编辑和 OAuth Provider 认证管理。
## 功能
- 📝 **配置编辑** — 侧边栏式设置 UI支持模型管理、通道配置表单和原始 JSON 编辑器
- 🤖 **模型管理** — 模型卡片网格,可用性状态显示(无 API Key 时灰色),主模型选择,增删改查,必填/选填字段分离
- 📡 **通道配置** — 12 种通道类型Telegram、Discord、Slack、企业微信、钉钉、飞书、LINE、WhatsApp、QQ、OneBot、MaixCAM 等)的表单化配置,附带文档链接
- 🔐 **Provider 认证** — 支持 OpenAI (Device Code)、Anthropic (API Token)、Google Antigravity (Browser OAuth) 登录
- 🌐 **嵌入式前端** — 编译为单一二进制文件,无需额外依赖
- 🌍 **国际化** — 中英文切换,首次访问自动检测浏览器语言
- 🎨 **主题** — 亮色 / 暗色 / 跟随系统,偏好保存在 localStorage
## 快速开始
```bash
# 编译
go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
# 运行(使用默认配置路径 ~/.picoclaw/config.json
./picoclaw-launcher
# 指定配置文件
./picoclaw-launcher ./config.json
# 允许局域网访问
./picoclaw-launcher -public
```
启动后在浏览器中打开 `http://localhost:18800`
## 命令行参数
```
Usage: picoclaw-launcher [options] [config.json]
Arguments:
config.json 配置文件路径(默认: ~/.picoclaw/config.json
Options:
-public 监听所有网络接口0.0.0.0),允许局域网设备访问
```
## API 文档
Base URL: `http://localhost:18800`
### 静态文件
#### GET /
提供嵌入式前端页面(`index.html`)。
---
### Config API
#### GET /api/config
读取当前配置文件内容。
**Response** `200 OK`
```json
{
"config": { ... },
"path": "/Users/xiao/.picoclaw/config.json"
}
```
---
#### PUT /api/config
保存配置。请求体为完整的 Config JSON。
**Request Body** — `application/json`
```json
{
"agents": { "defaults": { "model_name": "gpt-5.2" } },
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"auth_method": "oauth"
}
]
}
```
**Response** `200 OK`
```json
{ "status": "ok" }
```
**Error** `400 Bad Request` — 无效 JSON
---
### Auth API
#### GET /api/auth/status
获取所有 Provider 的认证状态和进行中的 Device Code 登录信息。
**Response** `200 OK`
```json
{
"providers": [
{
"provider": "openai",
"auth_method": "oauth",
"status": "active",
"account_id": "user-xxx",
"expires_at": "2026-03-01T00:00:00Z"
}
],
"pending_device": {
"provider": "openai",
"status": "pending",
"device_url": "https://auth.openai.com/activate",
"user_code": "ABCD-1234"
}
}
```
`status` 可选值: `active` | `expired` | `needs_refresh`
`pending_device` 仅在有进行中的 Device Code 登录时返回。
---
#### POST /api/auth/login
发起 Provider 登录。
**Request Body** — `application/json`
```json
{ "provider": "openai" }
```
支持的 `provider` 值: `openai` | `anthropic` | `google-antigravity`
##### OpenAI (Device Code Flow)
返回 Device Code 信息,后台自动轮询认证结果:
```json
{
"status": "pending",
"device_url": "https://auth.openai.com/activate",
"user_code": "ABCD-1234",
"message": "Open the URL and enter the code to authenticate."
}
```
用户在浏览器中打开 `device_url` 并输入 `user_code`。认证完成后通过 `GET /api/auth/status``pending_device.status` 变为 `success` 通知前端。
##### Anthropic (API Token)
需在请求中附带 token
```json
{ "provider": "anthropic", "token": "sk-ant-xxx" }
```
**Response:**
```json
{ "status": "success", "message": "Anthropic token saved" }
```
##### Google Antigravity (Browser OAuth)
返回授权 URL前端打开新标签页
```json
{
"status": "redirect",
"auth_url": "https://accounts.google.com/o/oauth2/auth?...",
"message": "Open the URL to authenticate with Google."
}
```
认证完成后 Google 回调至 `GET /auth/callback`,自动保存凭据并重定向回 picoclaw-config 页面。
---
#### POST /api/auth/logout
登出 Provider。
**Request Body** — `application/json`
```json
{ "provider": "openai" }
```
传空字符串或省略 `provider` 则登出所有 Provider。
**Response** `200 OK`
```json
{ "status": "ok" }
```
---
#### GET /auth/callback
OAuth Browser 回调端点Google Antigravity 专用),由 OAuth Provider 重定向调用,**非前端直接使用**。
**Query Parameters:**
- `state` — OAuth state 校验
- `code` — 授权码
认证成功后重定向到 `/#auth`
### Process API
#### GET /api/process/status
获取 `picoclaw gateway` 进程的运行状态。
**Response** `200 OK` (运行中)
```json
{
"process_status": "running",
"status": "ok",
"uptime": "1.010814s"
}
```
**Response** `200 OK` (未运行)
```json
{
"process_status": "stopped",
"error": "Get \"http://localhost:18790/health\": dial tcp [::1]:18790: connect: connection refused"
}
```
---
#### POST /api/process/start
在后台启动 `picoclaw gateway` 进程。
**Response** `200 OK`
```json
{
"status": "ok",
"pid": 12345
}
```
---
#### POST /api/process/stop
停止正在运行的 `picoclaw gateway` 进程。
**Response** `200 OK`
```json
{
"status": "ok"
}
```
---
## 测试
```bash
go test -v ./cmd/picoclaw-launcher/
```

View file

@ -1,147 +0,0 @@
package server
import (
"log"
"strings"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
// updateConfigAfterLogin updates config.json after a successful provider login.
func updateConfigAfterLogin(configPath, provider string, cred *auth.AuthCredential) {
cfg, err := config.LoadConfig(configPath)
if err != nil {
log.Printf("Warning: could not load config to update auth_method: %v", err)
return
}
switch provider {
case "openai":
cfg.Providers.OpenAI.AuthMethod = "oauth"
found := false
for i := range cfg.ModelList {
if isOpenAIModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = "oauth"
found = true
break
}
}
if !found {
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
AuthMethod: "oauth",
})
}
cfg.Agents.Defaults.ModelName = "gpt-5.2"
case "anthropic":
cfg.Providers.Anthropic.AuthMethod = "token"
found := false
for i := range cfg.ModelList {
if isAnthropicModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = "token"
found = true
break
}
}
if !found {
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
ModelName: "claude-sonnet-4.6",
Model: "anthropic/claude-sonnet-4.6",
AuthMethod: "token",
})
}
cfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
case "google-antigravity":
cfg.Providers.Antigravity.AuthMethod = "oauth"
found := false
for i := range cfg.ModelList {
if isAntigravityModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = "oauth"
found = true
break
}
}
if !found {
cfg.ModelList = append(cfg.ModelList, config.ModelConfig{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: "oauth",
})
}
cfg.Agents.Defaults.ModelName = "gemini-flash"
}
if err := config.SaveConfig(configPath, cfg); err != nil {
log.Printf("Warning: could not update config: %v", err)
}
}
// clearAuthMethodInConfig clears auth_method for a specific provider in config.json.
func clearAuthMethodInConfig(configPath, provider string) {
cfg, err := config.LoadConfig(configPath)
if err != nil {
return
}
for i := range cfg.ModelList {
switch provider {
case "openai":
if isOpenAIModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = ""
}
case "anthropic":
if isAnthropicModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = ""
}
case "google-antigravity", "antigravity":
if isAntigravityModel(cfg.ModelList[i].Model) {
cfg.ModelList[i].AuthMethod = ""
}
}
}
switch provider {
case "openai":
cfg.Providers.OpenAI.AuthMethod = ""
case "anthropic":
cfg.Providers.Anthropic.AuthMethod = ""
case "google-antigravity", "antigravity":
cfg.Providers.Antigravity.AuthMethod = ""
}
config.SaveConfig(configPath, cfg)
}
// clearAllAuthMethodsInConfig clears auth_method for all providers in config.json.
func clearAllAuthMethodsInConfig(configPath string) {
cfg, err := config.LoadConfig(configPath)
if err != nil {
return
}
for i := range cfg.ModelList {
cfg.ModelList[i].AuthMethod = ""
}
cfg.Providers.OpenAI.AuthMethod = ""
cfg.Providers.Anthropic.AuthMethod = ""
cfg.Providers.Antigravity.AuthMethod = ""
config.SaveConfig(configPath, cfg)
}
// ── Model identification helpers ─────────────────────────────────
func isOpenAIModel(model string) bool {
return model == "openai" || strings.HasPrefix(model, "openai/")
}
func isAnthropicModel(model string) bool {
return model == "anthropic" || strings.HasPrefix(model, "anthropic/")
}
func isAntigravityModel(model string) bool {
return model == "antigravity" || model == "google-antigravity" ||
strings.HasPrefix(model, "antigravity/") || strings.HasPrefix(model, "google-antigravity/")
}

View file

@ -1,222 +0,0 @@
package server
import (
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
// ── Model identification helpers ─────────────────────────────────
func TestIsOpenAIModel(t *testing.T) {
tests := []struct {
model string
want bool
}{
{"openai", true},
{"openai/gpt-4o", true},
{"openai/gpt-5.2", true},
{"anthropic", false},
{"anthropic/claude-sonnet-4.6", false},
{"openai-compatible", false},
{"", false},
}
for _, tt := range tests {
if got := isOpenAIModel(tt.model); got != tt.want {
t.Errorf("isOpenAIModel(%q) = %v, want %v", tt.model, got, tt.want)
}
}
}
func TestIsAnthropicModel(t *testing.T) {
tests := []struct {
model string
want bool
}{
{"anthropic", true},
{"anthropic/claude-sonnet-4.6", true},
{"openai", false},
{"openai/gpt-4o", false},
{"", false},
}
for _, tt := range tests {
if got := isAnthropicModel(tt.model); got != tt.want {
t.Errorf("isAnthropicModel(%q) = %v, want %v", tt.model, got, tt.want)
}
}
}
func TestIsAntigravityModel(t *testing.T) {
tests := []struct {
model string
want bool
}{
{"antigravity", true},
{"google-antigravity", true},
{"antigravity/gemini-3-flash", true},
{"google-antigravity/gemini-3-flash", true},
{"openai", false},
{"antigravity-custom", false},
{"", false},
}
for _, tt := range tests {
if got := isAntigravityModel(tt.model); got != tt.want {
t.Errorf("isAntigravityModel(%q) = %v, want %v", tt.model, got, tt.want)
}
}
}
// ── Config update helpers ────────────────────────────────────────
func writeTempConfigViaSave(t *testing.T, cfg *config.Config) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := config.SaveConfig(path, cfg); err != nil {
t.Fatalf("save config: %v", err)
}
return path
}
func loadTempConfig(t *testing.T, path string) *config.Config {
t.Helper()
cfg, err := config.LoadConfig(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
return cfg
}
func TestUpdateConfigAfterLogin_OpenAI_ExistingModel(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
},
}
path := writeTempConfigViaSave(t, cfg)
cred := &auth.AuthCredential{AuthMethod: "oauth"}
updateConfigAfterLogin(path, "openai", cred)
result := loadTempConfig(t, path)
// Model-level auth_method persists through serialization
if len(result.ModelList) != 1 {
t.Fatalf("expected 1 model, got %d", len(result.ModelList))
}
if result.ModelList[0].AuthMethod != "oauth" {
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
}
}
func TestUpdateConfigAfterLogin_OpenAI_NoExistingModel(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6"},
},
}
path := writeTempConfigViaSave(t, cfg)
cred := &auth.AuthCredential{AuthMethod: "oauth"}
updateConfigAfterLogin(path, "openai", cred)
result := loadTempConfig(t, path)
if len(result.ModelList) != 2 {
t.Fatalf("expected 2 models (original + added), got %d", len(result.ModelList))
}
if result.ModelList[1].Model != "openai/gpt-5.2" {
t.Errorf("expected added model openai/gpt-5.2, got %q", result.ModelList[1].Model)
}
if result.Agents.Defaults.ModelName != "gpt-5.2" {
t.Errorf("expected default model_name=gpt-5.2, got %q", result.Agents.Defaults.ModelName)
}
}
func TestUpdateConfigAfterLogin_Anthropic(t *testing.T) {
cfg := &config.Config{}
path := writeTempConfigViaSave(t, cfg)
cred := &auth.AuthCredential{AuthMethod: "token"}
updateConfigAfterLogin(path, "anthropic", cred)
result := loadTempConfig(t, path)
// Model should be added with correct auth_method
if len(result.ModelList) != 1 {
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
}
if result.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", result.ModelList[0].Model)
}
if result.ModelList[0].AuthMethod != "token" {
t.Errorf("expected model auth_method=token, got %q", result.ModelList[0].AuthMethod)
}
}
func TestUpdateConfigAfterLogin_GoogleAntigravity(t *testing.T) {
cfg := &config.Config{}
path := writeTempConfigViaSave(t, cfg)
cred := &auth.AuthCredential{AuthMethod: "oauth"}
updateConfigAfterLogin(path, "google-antigravity", cred)
result := loadTempConfig(t, path)
// Model should be added with correct auth_method
if len(result.ModelList) != 1 {
t.Fatalf("expected 1 model added, got %d", len(result.ModelList))
}
if result.ModelList[0].Model != "antigravity/gemini-3-flash" {
t.Errorf("expected model antigravity/gemini-3-flash, got %q", result.ModelList[0].Model)
}
if result.ModelList[0].AuthMethod != "oauth" {
t.Errorf("expected model auth_method=oauth, got %q", result.ModelList[0].AuthMethod)
}
}
func TestClearAuthMethodInConfig(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
},
}
path := writeTempConfigViaSave(t, cfg)
clearAuthMethodInConfig(path, "openai")
result := loadTempConfig(t, path)
// Openai model auth_method should be cleared
if result.ModelList[0].AuthMethod != "" {
t.Errorf("expected openai model auth_method cleared, got %q", result.ModelList[0].AuthMethod)
}
// Anthropic model should be unchanged
if result.ModelList[1].AuthMethod != "token" {
t.Errorf("expected anthropic model auth_method unchanged, got %q", result.ModelList[1].AuthMethod)
}
}
func TestClearAllAuthMethodsInConfig(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "gpt-4o", Model: "openai/gpt-4o", AuthMethod: "oauth"},
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
{ModelName: "gemini", Model: "antigravity/gemini-3-flash", AuthMethod: "oauth"},
},
}
path := writeTempConfigViaSave(t, cfg)
clearAllAuthMethodsInConfig(path)
result := loadTempConfig(t, path)
for i, m := range result.ModelList {
if m.AuthMethod != "" {
t.Errorf("model[%d] auth_method not cleared, got %q", i, m.AuthMethod)
}
}
}

View file

@ -1,315 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/providers"
)
// oauthSession stores in-flight OAuth state for browser-based flows.
type oauthSession struct {
Provider string
PKCE auth.PKCECodes
State string
RedirectURI string
OAuthCfg auth.OAuthProviderConfig
ConfigPath string
}
// deviceCodeSession stores in-flight device code flow state.
type deviceCodeSession struct {
mu sync.Mutex
Provider string
Info *auth.DeviceCodeInfo
OAuthCfg auth.OAuthProviderConfig
ConfigPath string
Status string // "pending", "success", "error"
Error string
Done bool
}
var (
oauthSessions = map[string]*oauthSession{} // keyed by state
oauthSessionsMu sync.Mutex
activeDeviceSession *deviceCodeSession
activeDeviceSessionMu sync.Mutex
)
// handleOpenAILogin starts the OpenAI device code flow and returns device code info to the frontend.
func handleOpenAILogin(w http.ResponseWriter, configPath string) {
// Check if there's already a pending device code session
activeDeviceSessionMu.Lock()
if activeDeviceSession != nil {
activeDeviceSession.mu.Lock()
if !activeDeviceSession.Done {
resp := map[string]any{
"status": "pending",
"device_url": activeDeviceSession.Info.VerifyURL,
"user_code": activeDeviceSession.Info.UserCode,
"message": "Device code flow already in progress. Enter the code in your browser.",
}
activeDeviceSession.mu.Unlock()
activeDeviceSessionMu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
return
}
activeDeviceSession.mu.Unlock()
}
activeDeviceSessionMu.Unlock()
// Request a device code
oauthCfg := auth.OpenAIOAuthConfig()
info, err := auth.RequestDeviceCode(oauthCfg)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to request device code: %v", err), http.StatusInternalServerError)
return
}
session := &deviceCodeSession{
Provider: "openai",
Info: info,
OAuthCfg: oauthCfg,
ConfigPath: configPath,
Status: "pending",
}
activeDeviceSessionMu.Lock()
activeDeviceSession = session
activeDeviceSessionMu.Unlock()
// Start background polling
go func() {
deadline := time.After(15 * time.Minute)
ticker := time.NewTicker(time.Duration(info.Interval) * time.Second)
defer ticker.Stop()
for {
select {
case <-deadline:
session.mu.Lock()
session.Status = "error"
session.Error = "Authentication timed out after 15 minutes"
session.Done = true
session.mu.Unlock()
return
case <-ticker.C:
cred, err := auth.PollDeviceCodeOnce(oauthCfg, info.DeviceAuthID, info.UserCode)
if err != nil {
continue // Still pending
}
if cred != nil {
if saveErr := auth.SetCredential("openai", cred); saveErr != nil {
session.mu.Lock()
session.Status = "error"
session.Error = saveErr.Error()
session.Done = true
session.mu.Unlock()
return
}
updateConfigAfterLogin(configPath, "openai", cred)
session.mu.Lock()
session.Status = "success"
session.Done = true
session.mu.Unlock()
log.Printf("OpenAI device code login successful (account: %s)", cred.AccountID)
return
}
}
}
}()
// Return device code info to frontend
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "pending",
"device_url": info.VerifyURL,
"user_code": info.UserCode,
"message": "Open the URL and enter the code to authenticate.",
})
}
// handleAnthropicLogin saves a pasted API token for Anthropic.
func handleAnthropicLogin(w http.ResponseWriter, token, configPath string) {
if token == "" {
http.Error(w, "Token is required for Anthropic login", http.StatusBadRequest)
return
}
cred := &auth.AuthCredential{
AccessToken: token,
Provider: "anthropic",
AuthMethod: "token",
}
if err := auth.SetCredential("anthropic", cred); err != nil {
http.Error(w, fmt.Sprintf("Failed to save credentials: %v", err), http.StatusInternalServerError)
return
}
updateConfigAfterLogin(configPath, "anthropic", cred)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "success",
"message": "Anthropic token saved",
})
}
// handleGoogleAntigravityLogin generates a PKCE + auth URL and returns it to the frontend.
func handleGoogleAntigravityLogin(w http.ResponseWriter, r *http.Request, configPath string) {
oauthCfg := auth.GoogleAntigravityOAuthConfig()
pkce, err := auth.GeneratePKCE()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to generate PKCE: %v", err), http.StatusInternalServerError)
return
}
state, err := auth.GenerateState()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to generate state: %v", err), http.StatusInternalServerError)
return
}
// Build redirect URI pointing to picoclaw-launcher's own callback
scheme := "http"
redirectURI := fmt.Sprintf("%s://%s/auth/callback", scheme, r.Host)
authURL := auth.BuildAuthorizeURL(oauthCfg, pkce, state, redirectURI)
// Store session for callback
oauthSessionsMu.Lock()
oauthSessions[state] = &oauthSession{
Provider: "google-antigravity",
PKCE: pkce,
State: state,
RedirectURI: redirectURI,
OAuthCfg: oauthCfg,
ConfigPath: configPath,
}
oauthSessionsMu.Unlock()
// Clean up stale sessions after 10 minutes
go func() {
time.Sleep(10 * time.Minute)
oauthSessionsMu.Lock()
delete(oauthSessions, state)
oauthSessionsMu.Unlock()
}()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "redirect",
"auth_url": authURL,
"message": "Open the URL to authenticate with Google.",
})
}
// handleOAuthCallback processes the OAuth callback from Google Antigravity.
func handleOAuthCallback(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
oauthSessionsMu.Lock()
session, ok := oauthSessions[state]
if ok {
delete(oauthSessions, state)
}
oauthSessionsMu.Unlock()
if !ok {
http.Error(w, "Invalid or expired OAuth state", http.StatusBadRequest)
return
}
if code == "" {
errMsg := r.URL.Query().Get("error")
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(
w,
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
errMsg,
)
return
}
cred, err := auth.ExchangeCodeForTokens(session.OAuthCfg, code, session.PKCE.CodeVerifier, session.RedirectURI)
if err != nil {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(
w,
`<html><body><h2>Authentication failed</h2><p>%s</p><p>You can close this window.</p></body></html>`,
err.Error(),
)
return
}
cred.Provider = session.Provider
// Fetch user info for Google Antigravity
if session.Provider == "google-antigravity" {
if email, err := fetchGoogleUserEmail(cred.AccessToken); err == nil {
cred.Email = email
}
if projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken); err == nil {
cred.ProjectID = projectID
}
}
if err := auth.SetCredential(session.Provider, cred); err != nil {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<html><body><h2>Failed to save credentials</h2><p>%s</p></body></html>`, err.Error())
return
}
updateConfigAfterLogin(session.ConfigPath, session.Provider, cred)
// Redirect back to picoclaw-launcher UI
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<html><body>
<h2>Authentication successful!</h2>
<p>Redirecting back to Config Editor...</p>
<script>setTimeout(function(){ window.location.href = '/#auth'; }, 1000);</script>
</body></html>`)
}
// fetchGoogleUserEmail retrieves the user's email from Google's userinfo endpoint.
func fetchGoogleUserEmail(accessToken string) (string, error) {
req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("reading userinfo response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("userinfo request failed: %s", string(body))
}
var userInfo struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &userInfo); err != nil {
return "", err
}
return userInfo.Email, nil
}

View file

@ -1,116 +0,0 @@
package server
import (
"fmt"
"sync"
"testing"
"github.com/stretchr/testify/assert"
)
func TestLogBuffer_Basic(t *testing.T) {
buf := NewLogBuffer(5)
// Empty buffer
lines, total, runID := buf.LinesSince(0)
assert.Nil(t, lines)
assert.Equal(t, 0, total)
assert.Equal(t, 0, runID)
// Append some lines
buf.Append("line1")
buf.Append("line2")
buf.Append("line3")
lines, total, runID = buf.LinesSince(0)
assert.Equal(t, []string{"line1", "line2", "line3"}, lines)
assert.Equal(t, 3, total)
assert.Equal(t, 0, runID)
// Incremental read
lines, total, _ = buf.LinesSince(2)
assert.Equal(t, []string{"line3"}, lines)
assert.Equal(t, 3, total)
// No new lines
lines, total, _ = buf.LinesSince(3)
assert.Nil(t, lines)
assert.Equal(t, 3, total)
}
func TestLogBuffer_Wrap(t *testing.T) {
buf := NewLogBuffer(3)
buf.Append("a")
buf.Append("b")
buf.Append("c")
buf.Append("d") // evicts "a"
buf.Append("e") // evicts "b"
lines, total, _ := buf.LinesSince(0)
assert.Equal(t, []string{"c", "d", "e"}, lines)
assert.Equal(t, 5, total)
// Incremental after wrap
lines, total, _ = buf.LinesSince(3)
assert.Equal(t, []string{"d", "e"}, lines)
assert.Equal(t, 5, total)
// Offset too old (before buffer start), get all buffered
lines, total, _ = buf.LinesSince(1)
assert.Equal(t, []string{"c", "d", "e"}, lines)
assert.Equal(t, 5, total)
}
func TestLogBuffer_Reset(t *testing.T) {
buf := NewLogBuffer(5)
buf.Append("before")
assert.Equal(t, 0, buf.RunID())
buf.Reset()
assert.Equal(t, 1, buf.RunID())
assert.Equal(t, 0, buf.Total())
lines, total, runID := buf.LinesSince(0)
assert.Nil(t, lines)
assert.Equal(t, 0, total)
assert.Equal(t, 1, runID)
buf.Append("after")
lines, total, runID = buf.LinesSince(0)
assert.Equal(t, []string{"after"}, lines)
assert.Equal(t, 1, total)
assert.Equal(t, 1, runID)
}
func TestLogBuffer_Concurrent(t *testing.T) {
buf := NewLogBuffer(100)
var wg sync.WaitGroup
// 10 writers
for i := range 10 {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range 50 {
buf.Append(fmt.Sprintf("writer-%d-line-%d", id, j))
}
}(i)
}
// 5 readers
for range 5 {
wg.Add(1)
go func() {
defer wg.Done()
for range 100 {
buf.LinesSince(0)
}
}()
}
wg.Wait()
assert.Equal(t, 500, buf.Total())
}

View file

@ -1,232 +0,0 @@
package server
import (
"bufio"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"time"
"github.com/sipeed/picoclaw/pkg/config"
)
// gatewayLogs stores captured stdout/stderr from the gateway process launched by the launcher.
var gatewayLogs = NewLogBuffer(200)
// RegisterProcessAPI registers endpoints to start, stop and check status of the picoclaw gateway.
func RegisterProcessAPI(mux *http.ServeMux, absPath string) {
mux.HandleFunc("GET /api/process/status", func(w http.ResponseWriter, r *http.Request) {
handleStatusGateway(w, r, absPath)
})
mux.HandleFunc("POST /api/process/start", handleStartGateway)
mux.HandleFunc("POST /api/process/stop", handleStopGateway)
}
func handleStartGateway(w http.ResponseWriter, r *http.Request) {
// Locate picoclaw executable:
// 1. Try same directory as current executable
// 2. Fallback to just "picoclaw" (relies on $PATH)
execPath := "picoclaw"
if exe, err := os.Executable(); err == nil {
dir := filepath.Dir(exe)
candidate := filepath.Join(dir, "picoclaw")
if runtime.GOOS == "windows" {
candidate += ".exe"
}
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
execPath = candidate
}
}
cmd := exec.Command(execPath, "gateway")
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
log.Printf("Failed to create stdout pipe: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
log.Printf("Failed to create stderr pipe: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
// Clear old logs and increment runID before starting
gatewayLogs.Reset()
if err := cmd.Start(); err != nil {
log.Printf("Failed to start picoclaw gateway: %v\n", err)
http.Error(w, fmt.Sprintf("Failed to start gateway: %v", err), http.StatusInternalServerError)
return
}
// Read stdout and stderr into the log buffer
go scanPipe(stdoutPipe, gatewayLogs)
go scanPipe(stderrPipe, gatewayLogs)
// Wait for the process to exit in the background to avoid zombies
go func() {
if err := cmd.Wait(); err != nil {
log.Printf("Gateway process exited: %v\n", err)
}
}()
log.Printf("Started picoclaw gateway (PID: %d) from %s\n", cmd.Process.Pid, execPath)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok",
"pid": cmd.Process.Pid,
})
}
// scanPipe reads lines from r and appends them to buf. It 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) // up to 1MB per line
for scanner.Scan() {
buf.Append(scanner.Text())
}
}
func handleStopGateway(w http.ResponseWriter, r *http.Request) {
var err error
if runtime.GOOS == "windows" {
// Kill via taskkill finding picoclaw.exe (though it might kill this config tool if it's named picoclaw-launcher.exe...? No, /IM does exact match usually, but just to be safe let's stop exactly picoclaw.exe)
// Alternatively, we use powershell to kill processes with commandline containing 'gateway'
psCmd := `Get-WmiObject Win32_Process | Where-Object { $_.CommandLine -match 'picoclaw.*gateway' } | ForEach-Object { Stop-Process $_.ProcessId -Force }`
err = exec.Command("powershell", "-Command", psCmd).Run()
} else {
// Linux/macOS
err = exec.Command("pkill", "-f", "picoclaw gateway").Run()
}
if err != nil {
log.Printf("Warning: Failed to stop gateway (perhaps not running?): %v\n", err)
// We still return 200 OK because pkill returns an error if no process was found
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"status": "ok", // or "not_found"
"msg": "Stop command executed, but returned error (process might not be running).",
"error": err.Error(),
})
return
}
log.Printf("Stopped picoclaw gateway processes.\n")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "ok",
})
}
func handleStatusGateway(w http.ResponseWriter, r *http.Request, absPath string) {
cfg, cfgErr := config.LoadConfig(absPath)
host := "127.0.0.1"
port := 18790
if cfgErr == nil && cfg != nil {
if cfg.Gateway.Host != "" && cfg.Gateway.Host != "0.0.0.0" {
host = cfg.Gateway.Host
}
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)
// Build the response data map
data := map[string]any{}
if err != nil {
data["process_status"] = "stopped"
data["error"] = err.Error()
} else {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
data["process_status"] = "error"
data["status_code"] = resp.StatusCode
} else {
var healthData map[string]any
if decErr := json.NewDecoder(resp.Body).Decode(&healthData); decErr != nil {
data["process_status"] = "error"
data["error"] = "invalid response from gateway"
} else {
// Gateway is running and responded properly — merge health data
for k, v := range healthData {
data[k] = v
}
data["process_status"] = "running"
}
}
}
// Append log data from the buffer
appendLogData(r, data)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
// appendLogData reads log_offset and log_run_id query params from the request and
// populates the response data map with incremental log lines.
func appendLogData(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 := gatewayLogs.RunID()
// If runID is 0 (never reset = never launched from this launcher), report no source
if runID == 0 {
data["logs"] = []string{}
data["log_total"] = 0
data["log_run_id"] = 0
data["log_source"] = "none"
return
}
// If the client's runID doesn't match, send all buffered lines (gateway restarted)
offset := clientOffset
if clientRunID != runID {
offset = 0
}
lines, total, runID := gatewayLogs.LinesSince(offset)
if lines == nil {
lines = []string{}
}
data["logs"] = lines
data["log_total"] = total
data["log_run_id"] = runID
data["log_source"] = "launcher"
}

View file

@ -1,196 +0,0 @@
package server
import (
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
const DefaultPort = "18800"
// providerStatus represents the auth status of a single provider in API responses.
type providerStatus struct {
Provider string `json:"provider"`
AuthMethod string `json:"auth_method"`
Status string `json:"status"`
AccountID string `json:"account_id,omitempty"`
Email string `json:"email,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// ── Route registration ───────────────────────────────────────────
func RegisterConfigAPI(mux *http.ServeMux, absPath string) {
// GET /api/config — read config
mux.HandleFunc("GET /api/config", func(w http.ResponseWriter, r *http.Request) {
cfg, err := config.LoadConfig(absPath)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{
"config": cfg,
"path": absPath,
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(resp); err != nil {
log.Printf("Failed to encode response: %v", err)
}
})
// PUT /api/config — save config
mux.HandleFunc("PUT /api/config", func(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 err := config.SaveConfig(absPath, &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 RegisterAuthAPI(mux *http.ServeMux, absPath string) {
// GET /api/auth/status — all authenticated providers + pending login state
mux.HandleFunc("GET /api/auth/status", func(w http.ResponseWriter, r *http.Request) {
store, err := auth.LoadStore()
if err != nil {
http.Error(w, fmt.Sprintf("Failed to load auth store: %v", err), http.StatusInternalServerError)
return
}
result := []providerStatus{}
for name, cred := range store.Credentials {
status := "active"
if cred.IsExpired() {
status = "expired"
} else if cred.NeedsRefresh() {
status = "needs_refresh"
}
ps := providerStatus{
Provider: name,
AuthMethod: cred.AuthMethod,
Status: status,
AccountID: cred.AccountID,
Email: cred.Email,
ProjectID: cred.ProjectID,
}
if !cred.ExpiresAt.IsZero() {
ps.ExpiresAt = cred.ExpiresAt.Format(time.RFC3339)
}
result = append(result, ps)
}
// Include pending device code state
var pendingDevice map[string]any
activeDeviceSessionMu.Lock()
if activeDeviceSession != nil {
activeDeviceSession.mu.Lock()
pendingDevice = map[string]any{
"provider": activeDeviceSession.Provider,
"status": activeDeviceSession.Status,
"device_url": activeDeviceSession.Info.VerifyURL,
"user_code": activeDeviceSession.Info.UserCode,
}
if activeDeviceSession.Error != "" {
pendingDevice["error"] = activeDeviceSession.Error
}
if activeDeviceSession.Done {
activeDeviceSession.mu.Unlock()
activeDeviceSession = nil
} else {
activeDeviceSession.mu.Unlock()
}
}
activeDeviceSessionMu.Unlock()
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"providers": result,
"pending_device": pendingDevice,
})
})
// POST /api/auth/login — initiate provider login
mux.HandleFunc("POST /api/auth/login", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"`
Token string `json:"token,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
switch req.Provider {
case "openai":
handleOpenAILogin(w, absPath)
case "anthropic":
handleAnthropicLogin(w, req.Token, absPath)
case "google-antigravity", "antigravity":
handleGoogleAntigravityLogin(w, r, absPath)
default:
http.Error(
w,
fmt.Sprintf(
"Unsupported provider: %s (supported: openai, anthropic, google-antigravity)",
req.Provider,
),
http.StatusBadRequest,
)
}
})
// POST /api/auth/logout — logout a provider
mux.HandleFunc("POST /api/auth/logout", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Provider string `json:"provider"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if req.Provider == "" {
if err := auth.DeleteAllCredentials(); err != nil {
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
return
}
clearAllAuthMethodsInConfig(absPath)
} else {
if err := auth.DeleteCredential(req.Provider); err != nil {
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
return
}
clearAuthMethodInConfig(absPath, req.Provider)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
})
// GET /auth/callback — OAuth browser callback for Google Antigravity
mux.HandleFunc("GET /auth/callback", handleOAuthCallback)
}

View file

@ -1,247 +0,0 @@
package server
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
// ── Config API tests ─────────────────────────────────────────────
func setupConfigMux(t *testing.T, cfg *config.Config) (*http.ServeMux, string) {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
t.Fatalf("marshal config: %v", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
mux := http.NewServeMux()
RegisterConfigAPI(mux, path)
RegisterAuthAPI(mux, path)
return mux, path
}
func TestGetConfig(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{
{ModelName: "gpt-4o", Model: "openai/gpt-4o"},
},
}
mux, path := setupConfigMux(t, cfg)
req := httptest.NewRequest("GET", "/api/config", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Config config.Config `json:"config"`
Path string `json:"path"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Path != path {
t.Errorf("expected path %q, got %q", path, resp.Path)
}
if len(resp.Config.ModelList) != 1 {
t.Errorf("expected 1 model, got %d", len(resp.Config.ModelList))
}
}
func TestGetConfig_MissingFile_ReturnsDefault(t *testing.T) {
mux := http.NewServeMux()
RegisterConfigAPI(mux, "/tmp/nonexistent-picoclaw-launcher-test/config.json")
req := httptest.NewRequest("GET", "/api/config", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
// LoadConfig returns a default empty config when file is missing
if w.Code != http.StatusOK {
t.Errorf("expected 200 for missing file (default config), got %d", w.Code)
}
}
func TestPutConfig(t *testing.T) {
cfg := &config.Config{}
mux, path := setupConfigMux(t, cfg)
newCfg := config.Config{
ModelList: []config.ModelConfig{
{ModelName: "claude", Model: "anthropic/claude-sonnet-4.6", AuthMethod: "token"},
},
}
body, _ := json.Marshal(newCfg)
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("PUT /api/config: expected 200, got %d: %s", w.Code, w.Body.String())
}
saved, err := config.LoadConfig(path)
if err != nil {
t.Fatalf("load saved config: %v", err)
}
if len(saved.ModelList) != 1 {
t.Fatalf("expected 1 model saved, got %d", len(saved.ModelList))
}
if saved.ModelList[0].Model != "anthropic/claude-sonnet-4.6" {
t.Errorf("expected model anthropic/claude-sonnet-4.6, got %q", saved.ModelList[0].Model)
}
}
func TestPutConfig_InvalidJSON(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("PUT", "/api/config", strings.NewReader("{invalid"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid JSON, got %d", w.Code)
}
}
// ── Auth API tests ───────────────────────────────────────────────
func TestAuthStatus(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("GET", "/api/auth/status", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET /api/auth/status: expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp struct {
Providers []providerStatus `json:"providers"`
PendingDevice map[string]any `json:"pending_device"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode response: %v", err)
}
// providers should be a non-nil list (could be empty)
if resp.Providers == nil {
t.Error("providers should not be nil")
}
}
func TestAuthLogin_UnsupportedProvider(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
body := `{"provider": "unsupported"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for unsupported provider, got %d", w.Code)
}
}
func TestAuthLogin_AnthropicNoToken(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
body := `{"provider": "anthropic"}`
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for anthropic without token, got %d", w.Code)
}
}
func TestAuthLogin_InvalidBody(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader("{bad"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid JSON body, got %d", w.Code)
}
}
func TestAuthLogout_InvalidBody(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("POST", "/api/auth/logout", strings.NewReader("{bad"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid body, got %d", w.Code)
}
}
func TestOAuthCallback_InvalidState(t *testing.T) {
cfg := &config.Config{}
mux, _ := setupConfigMux(t, cfg)
req := httptest.NewRequest("GET", "/auth/callback?state=invalid&code=test", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid state, got %d", w.Code)
}
}
// ── Utility tests ────────────────────────────────────────────────
func TestDefaultConfigPath(t *testing.T) {
path := DefaultConfigPath()
if path == "" {
t.Error("defaultConfigPath should not return empty")
}
if !strings.HasSuffix(path, filepath.Join(".picoclaw", "config.json")) {
t.Errorf("expected path ending with .picoclaw/config.json, got %q", path)
}
}
func TestGetLocalIP(t *testing.T) {
// Just ensure it doesn't panic; IP may or may not be available
ip := GetLocalIP()
if ip != "" {
// If returned, should look like an IP
if !strings.Contains(ip, ".") {
t.Errorf("getLocalIP returned non-IPv4 looking string: %q", ip)
}
}
}

View file

@ -1,28 +0,0 @@
package server
import (
"net"
"os"
"path/filepath"
)
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
return "config.json"
}
return filepath.Join(home, ".picoclaw", "config.json")
}
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 ""
}

File diff suppressed because it is too large Load diff

View file

@ -1,127 +0,0 @@
// PicoClaw Launcher - Standalone HTTP service
//
// Provides a web-based JSON editor for picoclaw config files,
// with OAuth provider authentication support.
//
// Usage:
//
// go build -o picoclaw-launcher ./cmd/picoclaw-launcher/
// ./picoclaw-launcher [config.json]
// ./picoclaw-launcher -public config.json
package main
import (
"embed"
"flag"
"fmt"
"io/fs"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher/internal/server"
)
//go:embed internal/ui/index.html
var staticFiles embed.FS
func main() {
public := flag.Bool("public", false, "Listen on all interfaces (0.0.0.0) instead of localhost only")
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()
configPath := server.DefaultConfigPath()
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)
}
var addr string
if *public {
addr = "0.0.0.0:" + server.DefaultPort
} else {
addr = "127.0.0.1:" + server.DefaultPort
}
mux := http.NewServeMux()
server.RegisterConfigAPI(mux, absPath)
server.RegisterAuthAPI(mux, absPath)
server.RegisterProcessAPI(mux, absPath)
staticFS, err := fs.Sub(staticFiles, "internal/ui")
if err != nil {
log.Fatalf("Failed to create sub filesystem: %v", err)
}
mux.Handle("/", http.FileServer(http.FS(staticFS)))
// Print startup banner
fmt.Println("=============================================")
fmt.Println(" PicoClaw Launcher")
fmt.Println("=============================================")
fmt.Printf(" Config file : %s\n", absPath)
fmt.Printf(" Listen addr : %s\n\n", addr)
fmt.Println(" Open the following URL in your browser")
fmt.Println(" to view and edit the configuration:")
fmt.Println()
fmt.Printf(" >> http://localhost:%s <<\n", server.DefaultPort)
if *public {
if ip := server.GetLocalIP(); ip != "" {
fmt.Printf(" >> http://%s:%s <<\n", ip, server.DefaultPort)
}
}
fmt.Println()
// fmt.Println("=============================================")
go func() {
// Wait briefly to ensure the server is ready before opening the browser
time.Sleep(500 * time.Millisecond)
url := "http://localhost:" + server.DefaultPort
if err := openBrowser(url); err != nil {
log.Printf("Warning: Failed to auto-open browser: %v\n", err)
}
}()
if err := http.ListenAndServe(addr, mux); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
// openBrowser automatically opens the given URL in the default browser.
func openBrowser(url string) error {
var err error
switch runtime.GOOS {
case "linux":
err = exec.Command("xdg-open", url).Start()
case "windows":
err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
case "darwin":
err = exec.Command("open", url).Start()
default:
err = fmt.Errorf("unsupported platform")
}
return err
}

View file

@ -9,7 +9,7 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/chzyer/readline" "github.com/ergochat/readline"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/agent"
@ -50,6 +50,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
msgBus := bus.NewMessageBus() msgBus := bus.NewMessageBus()
defer msgBus.Close() defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
defer agentLoop.Close()
// Print agent startup info (only for interactive mode) // Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo() startupInfo := agentLoop.GetStartupInfo()

View file

@ -72,14 +72,14 @@ func authLoginOpenAI(useDeviceCode bool) error {
// If no openai in ModelList, add it // If no openai in ModelList, add it
if !foundOpenAI { if !foundOpenAI {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2", ModelName: "gpt-5.4",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
AuthMethod: "oauth", AuthMethod: "oauth",
}) })
} }
// Update default model to use OpenAI // 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 { if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err) return fmt.Errorf("could not update config: %w", err)
@ -90,7 +90,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
if cred.AccountID != "" { if cred.AccountID != "" {
fmt.Printf("Account: %s\n", 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 return nil
} }
@ -318,13 +318,13 @@ func authLoginPasteToken(provider string) error {
} }
if !found { if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2", ModelName: "gpt-5.4",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
AuthMethod: "token", AuthMethod: "token",
}) })
} }
// Update default model // 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 { if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err) return fmt.Errorf("could not update config: %w", err)

View file

@ -1,23 +1,42 @@
package gateway package gateway
import ( import (
"fmt"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
) )
func NewGatewayCommand() *cobra.Command { func NewGatewayCommand() *cobra.Command {
var debug bool var debug bool
var noTruncate bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "gateway", Use: "gateway",
Aliases: []string{"g"}, Aliases: []string{"g"},
Short: "Start picoclaw gateway", Short: "Start picoclaw gateway",
Args: cobra.NoArgs, 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 { RunE: func(_ *cobra.Command, _ []string) error {
return gatewayCmd(debug) return gatewayCmd(debug)
}, },
} }
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
return cmd return cmd
} }

View file

@ -3,10 +3,10 @@ package gateway
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
@ -19,6 +19,7 @@ import (
_ "github.com/sipeed/picoclaw/pkg/channels/irc" _ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line" _ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam" _ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot" _ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq" _ "github.com/sipeed/picoclaw/pkg/channels/qq"
@ -40,12 +41,31 @@ import (
"github.com/sipeed/picoclaw/pkg/voice" "github.com/sipeed/picoclaw/pkg/voice"
) )
// Timeout constants for service operations
const (
serviceRestartTimeout = 30 * time.Second
serviceShutdownTimeout = 30 * time.Second
providerReloadTimeout = 30 * time.Second
gracefulShutdownTimeout = 15 * time.Second
)
// gatewayServices holds references to all running services
type gatewayServices struct {
CronService *cron.CronService
HeartbeatService *heartbeat.HeartbeatService
MediaStore media.MediaStore
ChannelManager *channels.Manager
DeviceService *devices.Service
HealthServer *health.Server
}
func gatewayCmd(debug bool) error { func gatewayCmd(debug bool) error {
if debug { if debug {
logger.SetLevel(logger.DEBUG) logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled") fmt.Println("🔍 Debug mode enabled")
} }
configPath := internal.GetConfigPath()
cfg, err := internal.LoadConfig() cfg, err := internal.LoadConfig()
if err != nil { if err != nil {
return fmt.Errorf("error loading config: %w", err) return fmt.Errorf("error loading config: %w", err)
@ -82,9 +102,55 @@ func gatewayCmd(debug bool) error {
"skills_available": skillsInfo["available"], "skills_available": skillsInfo["available"],
}) })
// Setup and start all services
services, err := setupAndStartServices(cfg, agentLoop, msgBus)
if err != nil {
return err
}
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go agentLoop.Run(ctx)
// Setup config file watcher for hot reload
configReloadChan, stopWatch := setupConfigWatcherPolling(configPath, debug)
defer stopWatch()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
// Main event loop - wait for signals or config changes
for {
select {
case <-sigChan:
logger.Info("Shutting down...")
shutdownGateway(services, agentLoop, provider, true)
return nil
case newCfg := <-configReloadChan:
err := handleConfigReload(ctx, agentLoop, newCfg, &provider, services, msgBus)
if err != nil {
logger.Errorf("Config reload failed: %v", err)
}
}
}
}
// setupAndStartServices initializes and starts all services
func setupAndStartServices(
cfg *config.Config,
agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus,
) (*gatewayServices, error) {
services := &gatewayServices{}
// Setup cron tool and service // Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
cronService := setupCronTool( services.CronService = setupCronTool(
agentLoop, agentLoop,
msgBus, msgBus,
cfg.WorkspacePath(), cfg.WorkspacePath(),
@ -92,20 +158,26 @@ func gatewayCmd(debug bool) error {
execTimeout, execTimeout,
cfg, cfg,
) )
if err := services.CronService.Start(); err != nil {
return nil, fmt.Errorf("error starting cron service: %w", err)
}
fmt.Println("✓ Cron service started")
heartbeatService := heartbeat.NewHeartbeatService( // Setup heartbeat service
services.HeartbeatService = heartbeat.NewHeartbeatService(
cfg.WorkspacePath(), cfg.WorkspacePath(),
cfg.Heartbeat.Interval, cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled, cfg.Heartbeat.Enabled,
) )
heartbeatService.SetBus(msgBus) services.HeartbeatService.SetBus(msgBus)
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
// Use cli:direct as fallback if no valid channel // Use cli:direct as fallback if no valid channel
if channel == "" || chatID == "" { if channel == "" || chatID == "" {
channel, chatID = "cli", "direct" channel, chatID = "cli", "direct"
} }
// Use ProcessHeartbeat - no session history, each heartbeat is independent // Use ProcessHeartbeat - no session history, each heartbeat is independent
var response string var response string
var err error
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID) response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil { if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err)) return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
@ -117,24 +189,36 @@ func gatewayCmd(debug bool) error {
// sent to user via processSystemMessage when the async task completes // sent to user via processSystemMessage when the async task completes
return tools.SilentResult(response) return tools.SilentResult(response)
}) })
if err := services.HeartbeatService.Start(); err != nil {
return nil, fmt.Errorf("error starting heartbeat service: %w", err)
}
fmt.Println("✓ Heartbeat service started")
// Create media store for file lifecycle management with TTL cleanup // Create media store for file lifecycle management with TTL cleanup
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{ services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled, Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute, MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute, Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
}) })
mediaStore.Start() // Start the media store if it's a FileMediaStore with cleanup
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Start()
}
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore) // Create channel manager
var err error
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
if err != nil { if err != nil {
mediaStore.Stop() // Stop the media store if it's a FileMediaStore with cleanup
return fmt.Errorf("error creating channel manager: %w", err) if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
return nil, fmt.Errorf("error creating channel manager: %w", err)
} }
// Inject channel manager and media store into agent loop // Inject channel manager and media store into agent loop
agentLoop.SetChannelManager(channelManager) agentLoop.SetChannelManager(services.ChannelManager)
agentLoop.SetMediaStore(mediaStore) agentLoop.SetMediaStore(services.MediaStore)
// Wire up voice transcription if a supported provider is configured. // Wire up voice transcription if a supported provider is configured.
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
@ -142,82 +226,386 @@ func gatewayCmd(debug bool) error {
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
} }
enabledChannels := channelManager.GetEnabledChannels() enabledChannels := services.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 { if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
} else { } else {
fmt.Println("⚠ Warning: No channels enabled") fmt.Println("⚠ Warning: No channels enabled")
} }
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := cronService.Start(); err != nil {
fmt.Printf("Error starting cron service: %v\n", err)
}
fmt.Println("✓ Cron service started")
if err := heartbeatService.Start(); err != nil {
fmt.Printf("Error starting heartbeat service: %v\n", err)
}
fmt.Println("✓ Heartbeat service started")
stateManager := state.NewManager(cfg.WorkspacePath())
deviceService := devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager)
deviceService.SetBus(msgBus)
if err := deviceService.Start(ctx); err != nil {
fmt.Printf("Error starting device service: %v\n", err)
} else if cfg.Devices.Enabled {
fmt.Println("✓ Device event service started")
}
// Setup shared HTTP server with health endpoints and webhook handlers // Setup shared HTTP server with health endpoints and webhook handlers
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
channelManager.SetupHTTPServer(addr, healthServer) services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
if err := channelManager.StartAll(ctx); err != nil { if err := services.ChannelManager.StartAll(context.Background()); err != nil {
fmt.Printf("Error starting channels: %v\n", err) return nil, fmt.Errorf("error starting channels: %w", err)
return err
} }
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
go agentLoop.Run(ctx) // Setup state manager and device service
stateManager := state.NewManager(cfg.WorkspacePath())
sigChan := make(chan os.Signal, 1) services.DeviceService = devices.NewService(devices.Config{
signal.Notify(sigChan, os.Interrupt) Enabled: cfg.Devices.Enabled,
<-sigChan MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager)
fmt.Println("\nShutting down...") services.DeviceService.SetBus(msgBus)
if cp, ok := provider.(providers.StatefulProvider); ok { if err := services.DeviceService.Start(context.Background()); err != nil {
cp.Close() logger.ErrorCF("device", "Error starting device service", map[string]any{"error": err.Error()})
} else if cfg.Devices.Enabled {
fmt.Println("✓ Device event service started")
} }
cancel()
msgBus.Close()
// Use a fresh context with timeout for graceful shutdown, return services, nil
// since the original ctx is already canceled. }
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
// stopAndCleanupServices stops all services and cleans up resources
func stopAndCleanupServices(
services *gatewayServices,
shutdownTimeout time.Duration,
) {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer shutdownCancel() defer shutdownCancel()
channelManager.StopAll(shutdownCtx) if services.ChannelManager != nil {
deviceService.Stop() services.ChannelManager.StopAll(shutdownCtx)
heartbeatService.Stop() }
cronService.Stop() if services.DeviceService != nil {
mediaStore.Stop() services.DeviceService.Stop()
}
if services.HeartbeatService != nil {
services.HeartbeatService.Stop()
}
if services.CronService != nil {
services.CronService.Stop()
}
if services.MediaStore != nil {
// Stop the media store if it's a FileMediaStore with cleanup
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
}
}
// shutdownGateway performs a complete gateway shutdown
func shutdownGateway(
services *gatewayServices,
agentLoop *agent.AgentLoop,
provider providers.LLMProvider,
fullShutdown bool,
) {
if cp, ok := provider.(providers.StatefulProvider); ok && fullShutdown {
cp.Close()
}
stopAndCleanupServices(services, gracefulShutdownTimeout)
agentLoop.Stop() agentLoop.Stop()
fmt.Println("✓ Gateway stopped") agentLoop.Close()
logger.Info("✓ Gateway stopped")
}
// handleConfigReload handles config file reload by stopping all services,
// reloading the provider and config, and restarting services with the new config.
func handleConfigReload(
ctx context.Context,
al *agent.AgentLoop,
newCfg *config.Config,
providerRef *providers.LLMProvider,
services *gatewayServices,
msgBus *bus.MessageBus,
) error {
logger.Info("🔄 Config file changed, reloading...")
newModel := newCfg.Agents.Defaults.ModelName
if newModel == "" {
newModel = newCfg.Agents.Defaults.Model
}
logger.Infof(" New model is '%s', recreating provider...", newModel)
// Stop all services before reloading
logger.Info(" Stopping all services...")
stopAndCleanupServices(services, serviceShutdownTimeout)
// Create new provider from updated config first to ensure validity
// This will use the correct API key and settings from newCfg.ModelList
newProvider, newModelID, err := providers.CreateProvider(newCfg)
if err != nil {
logger.Errorf(" ⚠ Error creating new provider: %v", err)
logger.Warn(" Attempting to restart services with old provider and config...")
// Try to restart services with old configuration
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
}
return fmt.Errorf("error creating new provider: %w", err)
}
if newModelID != "" {
newCfg.Agents.Defaults.ModelName = newModelID
}
// Use the atomic reload method on AgentLoop to safely swap provider and config.
// This handles locking internally to prevent races with in-flight LLM calls
// and concurrent reads of registry/config while the swap occurs.
reloadCtx, reloadCancel := context.WithTimeout(context.Background(), providerReloadTimeout)
defer reloadCancel()
if err := al.ReloadProviderAndConfig(reloadCtx, newProvider, newCfg); err != nil {
logger.Errorf(" ⚠ Error reloading agent loop: %v", err)
// Close the newly created provider since it wasn't adopted
if cp, ok := newProvider.(providers.StatefulProvider); ok {
cp.Close()
}
logger.Warn(" Attempting to restart services with old provider and config...")
if restartErr := restartServices(al, services, msgBus); restartErr != nil {
logger.Errorf(" ⚠ Failed to restart services: %v", restartErr)
}
return fmt.Errorf("error reloading agent loop: %w", err)
}
// Update local provider reference only after successful atomic reload
*providerRef = newProvider
// Restart all services with new config
logger.Info(" Restarting all services with new configuration...")
if err := restartServices(al, services, msgBus); err != nil {
logger.Errorf(" ⚠ Error restarting services: %v", err)
return fmt.Errorf("error restarting services: %w", err)
}
logger.Info(" ✓ Provider, configuration, and services reloaded successfully (thread-safe)")
return nil
}
// restartServices restarts all services after a config reload
func restartServices(
al *agent.AgentLoop,
services *gatewayServices,
msgBus *bus.MessageBus,
) error {
// Create an independent context with timeout for service restart
// This prevents cancellation from the main loop context during reload
ctx, cancel := context.WithTimeout(context.Background(), serviceRestartTimeout)
defer cancel()
// Get current config from agent loop (which has been updated if this is a reload)
cfg := al.GetConfig()
// Re-create and start cron service with new config
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
services.CronService = setupCronTool(
al,
msgBus,
cfg.WorkspacePath(),
cfg.Agents.Defaults.RestrictToWorkspace,
execTimeout,
cfg,
)
if err := services.CronService.Start(); err != nil {
return fmt.Errorf("error restarting cron service: %w", err)
}
fmt.Println(" ✓ Cron service restarted")
// Re-create and start heartbeat service with new config
services.HeartbeatService = heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled,
)
services.HeartbeatService.SetBus(msgBus)
services.HeartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
if channel == "" || chatID == "" {
channel, chatID = "cli", "direct"
}
var response string
var err error
response, err = al.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
}
if response == "HEARTBEAT_OK" {
return tools.SilentResult("Heartbeat OK")
}
return tools.SilentResult(response)
})
if err := services.HeartbeatService.Start(); err != nil {
return fmt.Errorf("error restarting heartbeat service: %w", err)
}
fmt.Println(" ✓ Heartbeat service restarted")
// Stop the old media store before creating a new one
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
// Re-create media store with new config
services.MediaStore = media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
})
// Start the media store if it's a FileMediaStore with cleanup
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Start()
}
al.SetMediaStore(services.MediaStore)
// Re-create channel manager with new config
var err error
services.ChannelManager, err = channels.NewManager(cfg, msgBus, services.MediaStore)
if err != nil {
// Stop the media store if it's a FileMediaStore with cleanup
if fms, ok := services.MediaStore.(*media.FileMediaStore); ok {
fms.Stop()
}
return fmt.Errorf("error recreating channel manager: %w", err)
}
al.SetChannelManager(services.ChannelManager)
enabledChannels := services.ChannelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf(" ✓ Channels enabled: %s\n", enabledChannels)
} else {
fmt.Println(" ⚠ Warning: No channels enabled")
}
// Setup HTTP server with new config
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
services.HealthServer = health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
services.ChannelManager.SetupHTTPServer(addr, services.HealthServer)
if err := services.ChannelManager.StartAll(ctx); err != nil {
return fmt.Errorf("error restarting channels: %w", err)
}
fmt.Printf(
" ✓ Channels restarted, health endpoints at http://%s:%d/health and ready\n",
cfg.Gateway.Host,
cfg.Gateway.Port,
)
// Re-create device service with new config
stateManager := state.NewManager(cfg.WorkspacePath())
services.DeviceService = devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager)
services.DeviceService.SetBus(msgBus)
if err := services.DeviceService.Start(ctx); err != nil {
logger.WarnCF("device", "Failed to restart device service", map[string]any{"error": err.Error()})
} else if cfg.Devices.Enabled {
fmt.Println(" ✓ Device event service restarted")
}
// Wire up voice transcription with new config
transcriber := voice.DetectTranscriber(cfg)
al.SetTranscriber(transcriber) // This will set it to nil if disabled
if transcriber != nil {
logger.InfoCF("voice", "Transcription re-enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
} else {
logger.InfoCF("voice", "Transcription disabled", nil)
}
return nil return nil
} }
// setupConfigWatcherPolling sets up a simple polling-based config file watcher
// Returns a channel for config updates and a stop function
func setupConfigWatcherPolling(configPath string, debug bool) (chan *config.Config, func()) {
configChan := make(chan *config.Config, 1)
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// Get initial file info
lastModTime := getFileModTime(configPath)
lastSize := getFileSize(configPath)
ticker := time.NewTicker(2 * time.Second) // Check every 2 seconds
defer ticker.Stop()
for {
select {
case <-ticker.C:
currentModTime := getFileModTime(configPath)
currentSize := getFileSize(configPath)
// Check if file changed (modification time or size changed)
if currentModTime.After(lastModTime) || currentSize != lastSize {
if debug {
logger.Debugf("🔍 Config file change detected")
}
// Debounce - wait a bit to ensure file write is complete
time.Sleep(500 * time.Millisecond)
// Validate and load new config
newCfg, err := config.LoadConfig(configPath)
if err != nil {
logger.Errorf("⚠ Error loading new config: %v", err)
logger.Warn(" Using previous valid config")
continue
}
// Validate the new config
if err := newCfg.ValidateModelList(); err != nil {
logger.Errorf(" ⚠ New config validation failed: %v", err)
logger.Warn(" Using previous valid config")
continue
}
logger.Info("✓ Config file validated and loaded")
// Update last known state
lastModTime = currentModTime
lastSize = currentSize
// Send new config to main loop (non-blocking)
select {
case configChan <- newCfg:
default:
// Channel full, skip this update
logger.Warn("⚠ Previous config reload still in progress, skipping")
}
}
case <-stop:
return
}
}
}()
stopFunc := func() {
close(stop)
wg.Wait()
}
return configChan, stopFunc
}
// getFileModTime returns the modification time of a file, or zero time if file doesn't exist
func getFileModTime(path string) time.Time {
info, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return info.ModTime()
}
// getFileSize returns the size of a file, or 0 if file doesn't exist
func getFileSize(path string) int64 {
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.Size()
}
func setupCronTool( func setupCronTool(
agentLoop *agent.AgentLoop, agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus, msgBus *bus.MessageBus,
@ -237,7 +625,7 @@ func setupCronTool(
var err error var err error
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg) cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
if err != nil { if err != nil {
log.Fatalf("Critical error during CronTool initialization: %v", err) logger.Fatalf("Critical error during CronTool initialization: %v", err)
} }
agentLoop.RegisterTool(cronTool) agentLoop.RegisterTool(cronTool)

View file

@ -1,23 +1,14 @@
package internal package internal
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
) )
const Logo = "🦞" const Logo = "🦞"
var (
version = "dev"
gitCommit string
buildTime string
goVersion string
)
// GetPicoclawHome returns the picoclaw home directory. // GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw // Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string { func GetPicoclawHome() string {
@ -40,25 +31,19 @@ func LoadConfig() (*config.Config, error) {
} }
// FormatVersion returns the version string with optional git commit // FormatVersion returns the version string with optional git commit
// Deprecated: Use pkg/config.FormatVersion instead
func FormatVersion() string { func FormatVersion() string {
v := version return config.FormatVersion()
if gitCommit != "" {
v += fmt.Sprintf(" (git: %s)", gitCommit)
}
return v
} }
// FormatBuildInfo returns build time and go version info // FormatBuildInfo returns build time and go version info
// Deprecated: Use pkg/config.FormatBuildInfo instead
func FormatBuildInfo() (string, string) { func FormatBuildInfo() (string, string) {
build := buildTime return config.FormatBuildInfo()
goVer := goVersion
if goVer == "" {
goVer = runtime.Version()
}
return build, goVer
} }
// GetVersion returns the version string // GetVersion returns the version string
// Deprecated: Use pkg/config.GetVersion instead
func GetVersion() string { func GetVersion() string {
return version return config.GetVersion()
} }

View file

@ -40,65 +40,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
assert.Equal(t, want, got) 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) { func TestGetConfigPath_Windows(t *testing.T) {
if runtime.GOOS != "windows" { if runtime.GOOS != "windows" {
t.Skip("windows-specific HOME behavior varies; run on 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) 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)
}

View file

@ -0,0 +1,138 @@
package model
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
// LocalModel is a special model name that indicates that the model is local and with or without api_key.
const LocalModel = "local-model"
func NewModelCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "model [model_name]",
Short: "Show or change the default model",
Long: `Show or change the default model configuration.
If no argument is provided, shows the current default model.
If a model name is provided, sets it as the default model.
Examples:
picoclaw model # Show current default model
picoclaw model gpt-5.2 # Set gpt-5.2 as default
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
picoclaw model local-model # Set local VLLM server as default
Note: 'local-model' is a special value for using a local VLLM server
(running at localhost:8000 by default) which does not require an API key.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
configPath := internal.GetConfigPath()
// Load current config
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if len(args) == 0 {
// Show current default model
showCurrentModel(cfg)
return nil
}
// Set new default model
modelName := args[0]
return setDefaultModel(configPath, cfg, modelName)
},
}
return cmd
}
func showCurrentModel(cfg *config.Config) {
defaultModel := cfg.Agents.Defaults.ModelName
if defaultModel == "" {
defaultModel = cfg.Agents.Defaults.Model
}
if defaultModel == "" {
fmt.Println("No default model is currently set.")
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
} else {
fmt.Printf("Current default model: %s\n", defaultModel)
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
}
}
func listAvailableModels(cfg *config.Config) {
if len(cfg.ModelList) == 0 {
fmt.Println(" No models configured in model_list")
return
}
defaultModel := cfg.Agents.Defaults.ModelName
if defaultModel == "" {
defaultModel = cfg.Agents.Defaults.Model
}
for _, model := range cfg.ModelList {
marker := " "
if model.ModelName == defaultModel {
marker = "> "
}
if model.APIKey == "" {
continue
}
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
}
}
func setDefaultModel(configPath string, cfg *config.Config, modelName string) error {
// Validate that the model exists in model_list
modelFound := false
for _, model := range cfg.ModelList {
if model.APIKey != "" && model.ModelName == modelName {
modelFound = true
break
}
}
if !modelFound && modelName != LocalModel {
return fmt.Errorf("cannot found model '%s' in config", modelName)
}
// Update the default model
// Clear old model field and set new model_name
oldModel := cfg.Agents.Defaults.ModelName
if oldModel == "" {
oldModel = cfg.Agents.Defaults.Model
}
cfg.Agents.Defaults.ModelName = modelName
cfg.Agents.Defaults.Model = "" // Clear deprecated field
// Save config back to file
if err := config.SaveConfig(configPath, cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("✓ Default model changed from '%s' to '%s'\n",
formatModelName(oldModel), modelName)
fmt.Println("\nThe new default model will be used for all agent interactions.")
return nil
}
func formatModelName(name string) string {
if name == "" {
return "(none)"
}
return name
}

View file

@ -0,0 +1,369 @@
package model
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
var configPath = ""
func initTest(t *testing.T) {
tmpDir := t.TempDir()
configPath = filepath.Join(tmpDir, "config.json")
_ = os.Setenv("PICOCLAW_CONFIG", configPath)
}
// captureStdout captures stdout during the execution of fn and returns the captured output
func captureStdout(fn func()) string {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fn()
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
return buf.String()
}
func TestNewModelCommand(t *testing.T) {
cmd := NewModelCommand()
require.NotNil(t, cmd)
assert.Equal(t, "model [model_name]", cmd.Use)
assert.Equal(t, "Show or change the default model", cmd.Short)
assert.Len(t, cmd.Aliases, 0)
assert.False(t, cmd.HasFlags())
assert.Nil(t, cmd.Run)
assert.NotNil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
}
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "Current default model: gpt-4")
assert.Contains(t, output, "Available models in your config:")
assert.Contains(t, output, "gpt-4")
assert.Contains(t, output, "claude-3")
}
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "",
Model: "",
},
},
ModelList: []config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "No default model is currently set.")
assert.Contains(t, output, "Available models in your config:")
}
func TestShowCurrentModel_BackwardCompatibility(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Model: "legacy-model",
},
},
ModelList: []config.ModelConfig{},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "Current default model: legacy-model")
}
func TestListAvailableModels_Empty(t *testing.T) {
cfg := &config.Config{
ModelList: []config.ModelConfig{},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.Contains(t, output, "No models configured in model_list")
}
func TestListAvailableModels_WithModels(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKey: "test"},
{ModelName: "claude-3", Model: "anthropic/claude-3", APIKey: "test"},
{ModelName: "no-key-model", Model: "openai/test", APIKey: ""},
},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.NotEmpty(t, output)
assert.Contains(t, output, "> - gpt-4 (openai/gpt-4)")
assert.Contains(t, output, "claude-3 (anthropic/claude-3)")
assert.NotContains(t, output, "no-key-model")
}
func TestSetDefaultModel_ValidModel(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
{ModelName: "old-model", Model: "openai/old-model", APIKey: "test"},
},
}
output := captureStdout(func() {
err := setDefaultModel(configPath, cfg, "new-model")
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
// Verify config was updated
updatedCfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
assert.Empty(t, updatedCfg.Agents.Defaults.Model)
}
func TestSetDefaultModel_LegacyModelField(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Model: "legacy-old",
},
},
ModelList: []config.ModelConfig{
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
},
}
output := captureStdout(func() {
err := setDefaultModel(configPath, cfg, "new-model")
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'legacy-old' to 'new-model'")
}
func TestSetDefaultModel_InvalidModel(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
},
}
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
}
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "existing-model", Model: "openai/existing", APIKey: "test"},
{ModelName: "no-key-model", Model: "openai/nokey", APIKey: ""},
},
}
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
}
func TestSetDefaultModel_SaveConfigError(t *testing.T) {
// Use an invalid path to trigger save error
invalidPath := "/nonexistent/directory/config.json"
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "new-model", Model: "openai/new-model", APIKey: "test"},
},
}
err := setDefaultModel(invalidPath, cfg, "new-model")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to save config")
}
func TestFormatModelName(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", "(none)"},
{"simple model", "gpt-4", "gpt-4"},
{"model with version", "claude-sonnet-4.6", "claude-sonnet-4.6"},
{"model with spaces", "my model", "my model"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatModelName(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelCommandExecution_Show(t *testing.T) {
initTest(t)
// Create a test config
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "test-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "test-model", Model: "openai/test", APIKey: "test"},
},
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
cmd := NewModelCommand()
output := captureStdout(func() {
err = cmd.RunE(cmd, []string{})
assert.NoError(t, err)
})
assert.Contains(t, output, "Current default model: test-model")
}
func TestModelCommandExecution_Set(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "old-model", Model: "openai/old", APIKey: "test"},
{ModelName: "new-model", Model: "openai/new", APIKey: "test"},
},
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
cmd := NewModelCommand()
output := captureStdout(func() {
err = cmd.RunE(cmd, []string{"new-model"})
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
}
func TestModelCommandExecution_TooManyArgs(t *testing.T) {
cmd := NewModelCommand()
err := cmd.RunE(cmd, []string{"model1", "model2"})
assert.Error(t, err)
}
func TestListAvailableModels_MarkerLogic(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "middle-model",
},
},
ModelList: []config.ModelConfig{
{ModelName: "first-model", Model: "openai/first", APIKey: "test"},
{ModelName: "middle-model", Model: "openai/middle", APIKey: "test"},
{ModelName: "last-model", Model: "openai/last", APIKey: "test"},
},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.Contains(t, output, " - first-model (openai/first)")
assert.Contains(t, output, "> - middle-model (openai/middle)")
assert.Contains(t, output, " - last-model (openai/last)")
}

View file

@ -29,7 +29,15 @@ func NewSkillsCommand() *cobra.Command {
} }
d.workspace = cfg.WorkspacePath() d.workspace = cfg.WorkspacePath()
d.installer = skills.NewSkillInstaller(d.workspace) installer, err := skills.NewSkillInstaller(
d.workspace,
cfg.Tools.Skills.Github.Token,
cfg.Tools.Skills.Github.Proxy,
)
if err != nil {
return fmt.Errorf("error creating skills installer: %w", err)
}
d.installer = installer
// get global config directory and builtin skills directory // get global config directory and builtin skills directory
globalDir := filepath.Dir(internal.GetConfigPath()) globalDir := filepath.Dir(internal.GetConfigPath())

View file

@ -6,6 +6,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
) )
func statusCmd() { func statusCmd() {
@ -18,8 +19,8 @@ func statusCmd() {
configPath := internal.GetConfigPath() configPath := internal.GetConfigPath()
fmt.Printf("%s picoclaw Status\n", internal.Logo) fmt.Printf("%s picoclaw Status\n", internal.Logo)
fmt.Printf("Version: %s\n", internal.FormatVersion()) fmt.Printf("Version: %s\n", config.FormatVersion())
build, _ := internal.FormatBuildInfo() build, _ := config.FormatBuildInfo()
if build != "" { if build != "" {
fmt.Printf("Build: %s\n", build) fmt.Printf("Build: %s\n", build)
} }

View file

@ -6,6 +6,7 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
) )
func NewVersionCommand() *cobra.Command { func NewVersionCommand() *cobra.Command {
@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command {
} }
func printVersion() { func printVersion() {
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion()) fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
build, goVer := internal.FormatBuildInfo() build, goVer := config.FormatBuildInfo()
if build != "" { if build != "" {
fmt.Printf(" Build: %s\n", build) fmt.Printf(" Build: %s\n", build)
} }

View file

@ -18,19 +18,21 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
"github.com/sipeed/picoclaw/pkg/config"
) )
func NewPicoclawCommand() *cobra.Command { 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{ cmd := &cobra.Command{
Use: "picoclaw", Use: "picoclaw",
Short: short, Short: short,
Example: "picoclaw list", Example: "picoclaw version",
} }
cmd.AddCommand( cmd.AddCommand(
@ -42,6 +44,7 @@ func NewPicoclawCommand() *cobra.Command {
cron.NewCronCommand(), cron.NewCronCommand(),
migrate.NewMigrateCommand(), migrate.NewMigrateCommand(),
skills.NewSkillsCommand(), skills.NewSkillsCommand(),
model.NewModelCommand(),
version.NewVersionCommand(), version.NewVersionCommand(),
) )

View file

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
) )
func TestNewPicoclawCommand(t *testing.T) { func TestNewPicoclawCommand(t *testing.T) {
@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
require.NotNil(t, cmd) 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, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short) assert.Equal(t, short, cmd.Short)
@ -38,6 +39,7 @@ func TestNewPicoclawCommand(t *testing.T) {
"cron", "cron",
"gateway", "gateway",
"migrate", "migrate",
"model",
"onboard", "onboard",
"skills", "skills",
"status", "status",

View file

@ -3,7 +3,7 @@
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true, "restrict_to_workspace": true,
"model_name": "gpt4", "model_name": "gpt-5.4",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
"max_tool_iterations": 20, "max_tool_iterations": 20,
@ -13,8 +13,8 @@
}, },
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1" "api_base": "https://api.openai.com/v1"
}, },
@ -25,6 +25,13 @@
"api_base": "https://api.anthropic.com/v1", "api_base": "https://api.anthropic.com/v1",
"thinking_level": "high" "thinking_level": "high"
}, },
{
"_comment": "Anthropic Messages API - use native format for direct Anthropic API access",
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"api_key": "sk-ant-your-key",
"api_base": "https://api.anthropic.com"
},
{ {
"model_name": "gemini", "model_name": "gemini",
"model": "antigravity/gemini-2.0-flash", "model": "antigravity/gemini-2.0-flash",
@ -36,14 +43,31 @@
"api_key": "sk-your-deepseek-key" "api_key": "sk-your-deepseek-key"
}, },
{ {
"model_name": "loadbalanced-gpt4", "model_name": "longcat",
"model": "openai/gpt-5.2", "model": "longcat/LongCat-Flash-Thinking",
"api_key": "your-longcat-api-key"
},
{
"model_name": "modelscope-qwen",
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
"api_key": "your-modelscope-access-token",
"api_base": "https://api-inference.modelscope.cn/v1"
},
{
"model_name": "azure-gpt5",
"model": "azure/my-gpt5-deployment",
"api_key": "your-azure-api-key",
"api_base": "https://your-resource.openai.azure.com"
},
{
"model_name": "loadbalanced-gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-key1", "api_key": "sk-key1",
"api_base": "https://api1.example.com/v1" "api_base": "https://api1.example.com/v1"
}, },
{ {
"model_name": "loadbalanced-gpt4", "model_name": "loadbalanced-gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-key2", "api_key": "sk-key2",
"api_base": "https://api2.example.com/v1" "api_base": "https://api2.example.com/v1"
} }
@ -98,7 +122,8 @@
"encrypt_key": "", "encrypt_key": "",
"verification_token": "", "verification_token": "",
"allow_from": [], "allow_from": [],
"reasoning_channel_id": "" "reasoning_channel_id": "",
"random_reaction_emoji": []
}, },
"dingtalk": { "dingtalk": {
"enabled": false, "enabled": false,
@ -114,6 +139,23 @@
"allow_from": [], "allow_from": [],
"reasoning_channel_id": "" "reasoning_channel_id": ""
}, },
"matrix": {
"enabled": false,
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
"device_id": "",
"join_on_invite": true,
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"placeholder": {
"enabled": true,
"text": "Thinking... 💭"
},
"reasoning_channel_id": ""
},
"line": { "line": {
"enabled": false, "enabled": false,
"channel_secret": "YOUR_LINE_CHANNEL_SECRET", "channel_secret": "YOUR_LINE_CHANNEL_SECRET",
@ -176,8 +218,13 @@
"nickserv_password": "", "nickserv_password": "",
"sasl_user": "", "sasl_user": "",
"sasl_password": "", "sasl_password": "",
"channels": ["#mychannel"], "channels": [
"request_caps": ["server-time", "message-tags"], "#mychannel"
],
"request_caps": [
"server-time",
"message-tags"
],
"allow_from": [], "allow_from": [],
"group_trigger": { "group_trigger": {
"mention_only": true "mention_only": true
@ -251,6 +298,14 @@
"avian": { "avian": {
"api_key": "", "api_key": "",
"api_base": "https://api.avian.io/v1" "api_base": "https://api.avian.io/v1"
},
"longcat": {
"api_key": "",
"api_base": "https://api.longcat.chat/openai"
},
"modelscope": {
"api_key": "",
"api_base": "https://api-inference.modelscope.cn/v1"
} }
}, },
"tools": { "tools": {
@ -261,6 +316,9 @@
"brave": { "brave": {
"enabled": false, "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY", "api_key": "YOUR_BRAVE_API_KEY",
"api_keys": [
"YOUR_BRAVE_API_KEY"
],
"max_results": 5 "max_results": 5
}, },
"tavily": { "tavily": {
@ -275,7 +333,10 @@
}, },
"perplexity": { "perplexity": {
"enabled": false, "enabled": false,
"api_key": "", "api_key": "pplx-xxx",
"api_keys": [
"pplx-xxx"
],
"max_results": 5 "max_results": 5
}, },
"searxng": { "searxng": {
@ -298,6 +359,13 @@
}, },
"mcp": { "mcp": {
"enabled": false, "enabled": false,
"discovery": {
"enabled": false,
"ttl": 5,
"max_search_results": 5,
"use_bm25": true,
"use_regex": false
},
"servers": { "servers": {
"context7": { "context7": {
"enabled": false, "enabled": false,
@ -382,6 +450,10 @@
"max_response_size": 0 "max_response_size": 0
} }
}, },
"github": {
"proxy": "http://127.0.0.1:7891",
"token": ""
},
"max_concurrent_searches": 2, "max_concurrent_searches": 2,
"search_cache": { "search_cache": {
"max_size": 50, "max_size": 50,
@ -441,6 +513,9 @@
"enabled": false, "enabled": false,
"monitor_usb": true "monitor_usb": true
}, },
"voice": {
"echo_transcription": false
},
"gateway": { "gateway": {
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 18790 "port": 18790

View file

@ -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"]

View file

@ -19,7 +19,7 @@ services:
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot) # 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: picoclaw-gateway:
image: docker.io/sipeed/picoclaw:latest image: docker.io/sipeed/picoclaw:latest
@ -32,3 +32,21 @@ services:
# - "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
volumes: volumes:
- ./data:/root/.picoclaw - ./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

View file

@ -26,7 +26,8 @@
| app_secret | string | 是 | 飞书应用的 App Secret | | app_secret | string | 是 | 飞书应用的 App Secret |
| encrypt_key | string | 否 | 事件回调加密密钥 | | encrypt_key | string | 否 | 事件回调加密密钥 |
| verification_token | string | 否 | 用于Webhook事件验证的Token | | verification_token | string | 否 | 用于Webhook事件验证的Token |
| allow_from | array | 否 | 用户ID白名单空表示允许所有用户 | | allow_from | array | 否 | 用户ID白名单空表示所有用户 |
| random_reaction_emoji | array | 否 | 随机添加的表情列表,空则使用默认 "Pin" |
## 设置流程 ## 设置流程
@ -35,3 +36,4 @@
3. 配置事件订阅和Webhook URL 3. 配置事件订阅和Webhook URL
4. 设置加密(可选,生产环境建议启用) 4. 设置加密(可选,生产环境建议启用)
5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中 5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
6. 自定义你希望 PicoClaw react 你消息时的表情(可选, Reference URL: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce))

View file

@ -0,0 +1,62 @@
# Matrix Channel Configuration Guide
## 1. Example Configuration
Add this to `config.json`:
```json
{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
"device_id": "",
"join_on_invite": true,
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"placeholder": {
"enabled": true,
"text": "Thinking..."
},
"reasoning_channel_id": "",
"message_format": "richtext"
}
}
}
```
## 2. Field Reference
| Field | Type | Required | Description |
|----------------------|----------|----------|-------------|
| enabled | bool | Yes | Enable or disable the Matrix channel |
| homeserver | string | Yes | Matrix homeserver URL (for example `https://matrix.org`) |
| user_id | string | Yes | Bot Matrix user ID (for example `@bot:matrix.org`) |
| access_token | string | Yes | Bot access token |
| device_id | string | No | Optional Matrix device ID |
| join_on_invite | bool | No | Auto-join invited rooms |
| allow_from | []string | No | User whitelist (Matrix user IDs) |
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
| placeholder | object | No | Placeholder message config |
| reasoning_channel_id | string | No | Target channel for reasoning output |
| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only |
## 3. Currently Supported
- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)
- Configurable message format (`richtext` / `plain`)
- Incoming image/audio/video/file download (MediaStore first, local path fallback)
- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
- Outgoing image/audio/video/file upload and send
- Group trigger rules (including mention-only mode)
- Typing state (`m.typing`)
- Placeholder message + final reply replacement
- Auto-join invited rooms (can be disabled)
## 4. TODO
- Rich media metadata improvements (for example image/video size and thumbnails)

View file

@ -0,0 +1,59 @@
# Matrix 通道配置指南
## 1. 配置示例
`config.json` 中添加:
```json
{
"channels": {
"matrix": {
"enabled": true,
"homeserver": "https://matrix.org",
"user_id": "@your-bot:matrix.org",
"access_token": "YOUR_MATRIX_ACCESS_TOKEN",
"device_id": "",
"join_on_invite": true,
"allow_from": [],
"group_trigger": {
"mention_only": true
},
"placeholder": {
"enabled": true,
"text": "Thinking... 💭"
},
"reasoning_channel_id": ""
}
}
}
```
## 2. 参数说明
| 字段 | 类型 | 必填 | 说明 |
|----------------------|----------|------|------|
| enabled | bool | 是 | 是否启用 Matrix 通道 |
| homeserver | string | 是 | Matrix 服务器地址(例如 `https://matrix.org` |
| user_id | string | 是 | 机器人 Matrix 用户 ID例如 `@bot:matrix.org` |
| access_token | string | 是 | 机器人 access token |
| device_id | string | 否 | 设备 ID可选 |
| join_on_invite | bool | 否 | 是否自动加入邀请房间 |
| allow_from | []string | 否 | 白名单用户Matrix 用户 ID |
| group_trigger | object | 否 | 群聊触发策略(支持 `mention_only` / `prefixes` |
| placeholder | object | 否 | 占位消息配置 |
| reasoning_channel_id | string | 否 | 思维链输出目标通道 |
## 3. 当前支持
- 文本消息收发
- 图片/音频/视频/文件消息入站下载(写入 MediaStore / 本地路径回退)
- 音频消息按统一标记进入现有转写流程(`[audio: ...]`
- 图片/音频/视频/文件消息出站发送(上传到 Matrix 媒体库后发送)
- 群聊触发规则(支持仅 @ 提及时响应)
- Typing 状态(`m.typing`
- 占位消息(`Thinking... 💭`+ 最终回复替换
- 自动加入邀请房间(可关闭)
## 4. TODO
- 富媒体细节增强(如 image/video 的尺寸、缩略图等 metadata

33
docs/debug.md Normal file
View file

@ -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.

View file

@ -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: Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
1. **Model-centric**: Users care about models, not providers 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 3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
### 2.2 New Configuration Structure ### 2.2 New Configuration Structure
@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
"api_key": "sk-xxx" "api_key": "sk-xxx"
}, },
{ {
"model_name": "gpt-5.2", "model_name": "gpt-5.4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-xxx" "api_key": "sk-xxx"
}, },
{ {
@ -128,7 +128,7 @@ type Config struct {
type ModelConfig struct { type ModelConfig struct {
// Required // Required
ModelName string `json:"model_name"` // user-facing name (alias) 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 // Common config
APIBase string `json:"api_base,omitempty"` APIBase string `json:"api_base,omitempty"`
@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field:
"model": "deepseek-chat" "model": "deepseek-chat"
}, },
"coder": { "coder": {
"model": "gpt-5.2", "model": "gpt-5.4",
"system_prompt": "You are a coding assistant..." "system_prompt": "You are a coding assistant..."
}, },
"translator": { "translator": {
@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_
model_list: model_list:
- model_name: gpt-4o - model_name: gpt-4o
litellm_params: litellm_params:
model: openai/gpt-5.2 model: openai/gpt-5.4
api_key: xxx api_key: xxx
- model_name: my-custom - model_name: my-custom
litellm_params: litellm_params:

View file

@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages:
"agents": { "agents": {
"defaults": { "defaults": {
"provider": "openai", "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_list": [
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key", "api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1" "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 | | 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` | | `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` | | `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` | | `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 | | Field | Required | Description |
|-------|----------|-------------| |-------|----------|-------------|
| `model_name` | Yes | User-facing alias for the model | | `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_base` | No | API endpoint URL |
| `api_key` | No* | API authentication key | | `api_key` | No* | API authentication key |
| `proxy` | No | HTTP proxy URL | | `proxy` | No | HTTP proxy URL |
@ -130,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load:
"model_list": [ "model_list": [
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-key1", "api_key": "sk-key1",
"api_base": "https://api1.example.com/v1" "api_base": "https://api1.example.com/v1"
}, },
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-key2", "api_key": "sk-key2",
"api_base": "https://api2.example.com/v1" "api_base": "https://api2.example.com/v1"
}, },
{ {
"model_name": "gpt4", "model_name": "gpt4",
"model": "openai/gpt-5.2", "model": "openai/gpt-5.4",
"api_key": "sk-key3", "api_key": "sk-key3",
"api_base": "https://api3.example.com/v1" "api_base": "https://api3.example.com/v1"
} }

View file

@ -7,11 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
```json ```json
{ {
"tools": { "tools": {
"web": { ... }, "web": {
"mcp": { ... }, ...
"exec": { ... }, },
"cron": { ... }, "mcp": {
"skills": { ... } ...
},
"exec": {
...
},
"cron": {
...
},
"skills": {
...
}
} }
} }
``` ```
@ -23,7 +33,7 @@ Web tools are used for web search and fetching.
### Brave ### Brave
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ------------- | ------ | ------- | ------------------------- | |---------------|--------|---------|---------------------------|
| `enabled` | bool | false | Enable Brave search | | `enabled` | bool | false | Enable Brave search |
| `api_key` | string | - | Brave Search API key | | `api_key` | string | - | Brave Search API key |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
@ -31,14 +41,14 @@ Web tools are used for web search and fetching.
### DuckDuckGo ### DuckDuckGo
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ------------- | ---- | ------- | ------------------------- | |---------------|------|---------|---------------------------|
| `enabled` | bool | true | Enable DuckDuckGo search | | `enabled` | bool | true | Enable DuckDuckGo search |
| `max_results` | int | 5 | Maximum number of results | | `max_results` | int | 5 | Maximum number of results |
### Perplexity ### Perplexity
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ------------- | ------ | ------- | ------------------------- | |---------------|--------|---------|---------------------------|
| `enabled` | bool | false | Enable Perplexity search | | `enabled` | bool | false | Enable Perplexity search |
| `api_key` | string | - | Perplexity API key | | `api_key` | string | - | Perplexity API key |
| `max_results` | int | 5 | Maximum number of results | | `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. The exec tool is used to execute shell commands.
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ---------------------- | ----- | ------- | ------------------------------------------ | |------------------------|-------|---------|--------------------------------------------|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | | `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | | `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands:
"tools": { "tools": {
"exec": { "exec": {
"enable_deny_patterns": true, "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. The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ---------------------- | ---- | ------- | ---------------------------------------------- | |------------------------|------|---------|------------------------------------------------|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | | `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
## MCP Tool ## MCP Tool
The MCP tool enables integration with external Model Context Protocol servers. 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 ### Global Config
| Config | Type | Default | Description | | Config | Type | Default | Description |
| --------- | ------ | ------- | ----------------------------------- | |-------------|--------|---------|----------------------------------------------|
| `enabled` | bool | false | Enable MCP integration globally | | `enabled` | bool | false | Enable MCP integration globally |
| `servers` | object | `{}` | Map of server name to server config | | `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 ### Per-Server Config
| Config | Type | Required | Description | | Config | Type | Required | Description |
| ---------- | ------ | -------- | ------------------------------------------ | |------------|--------|----------|--------------------------------------------|
| `enabled` | bool | yes | Enable this MCP server | | `enabled` | bool | yes | Enable this MCP server |
| `type` | string | no | Transport type: `stdio`, `sse`, `http` | | `type` | string | no | Transport type: `stdio`, `sse`, `http` |
| `command` | string | stdio | Executable command for stdio transport | | `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 ### Transport Behavior
- If `type` is omitted, transport is auto-detected: - If `type` is omitted, transport is auto-detected:
- `url` is set → `sse` - `url` is set → `sse`
- `command` is set → `stdio` - `command` is set → `stdio`
- `http` and `sse` both use `url` + optional `headers`. - `http` and `sse` both use `url` + optional `headers`.
- `env` and `env_file` are only applied to `stdio` servers. - `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": { "filesystem": {
"enabled": true, "enabled": true,
"command": "npx", "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 ## Skills Tool
The skills tool configures skill discovery and installation via registries like ClawHub. The skills tool configures skill discovery and installation via registries like ClawHub.
### Registries ### Registries
| Config | Type | Default | Description | | Config | Type | Default | Description |
| ---------------------------------- | ------ | -------------------- | ----------------------- | |------------------------------------|--------|----------------------|----------------------------------------------|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry | | `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL | | `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.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search 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.skills_path` | string | `/api/v1/skills` | Skills API path |
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path | | `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
### Configuration Example ### Configuration Example
@ -217,4 +313,5 @@ For example:
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` - `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true` - `PICOCLAW_TOOLS_MCP_ENABLED=true`
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than environment variables. Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than
environment variables.

12
go.mod
View file

@ -7,8 +7,10 @@ require (
github.com/anthropics/anthropic-sdk-go v1.22.1 github.com/anthropics/anthropic-sdk-go v1.22.1
github.com/bwmarrin/discordgo v0.29.0 github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/chzyer/readline v1.5.1 github.com/ergochat/irc-go v0.5.0
github.com/ergochat/readline v0.1.3
github.com/gdamore/tcell/v2 v2.13.8 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/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3 github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3 github.com/h2non/filetype v1.1.3
@ -19,6 +21,7 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0 github.com/openai/openai-go/v3 v3.22.0
github.com/rivo/tview v0.42.0 github.com/rivo/tview v0.42.0
github.com/rs/zerolog v1.34.0
github.com/slack-go/slack v0.17.3 github.com/slack-go/slack v0.17.3
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
@ -27,6 +30,8 @@ require (
golang.org/x/oauth2 v0.35.0 golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11 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 modernc.org/sqlite v1.46.1
) )
@ -37,7 +42,6 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/ergochat/irc-go v0.5.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect github.com/gdamore/encoding v1.0.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@ -48,7 +52,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/asm v1.1.3 // indirect
github.com/segmentio/encoding v0.5.3 // indirect github.com/segmentio/encoding v0.5.3 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
@ -58,7 +61,6 @@ require (
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/term v0.40.0 // indirect golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.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/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
@ -89,7 +91,7 @@ require (
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.24.0 // indirect
golang.org/x/crypto v0.48.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.50.0 // indirect golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
) )

17
go.sum
View file

@ -27,12 +27,6 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
@ -50,6 +44,8 @@ github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw= github.com/ergochat/irc-go v0.5.0 h1:woQ1RS9YbfgqPgSpPBBQeczXGIGzR0aC7dEgk469fTw=
github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0= github.com/ergochat/irc-go v0.5.0/go.mod h1:2vi7KNpIPWnReB5hmLpl92eMywQvuIeIIGdt/FQCph0=
github.com/ergochat/readline v0.1.3 h1:/DytGTmwdUJcLAe3k3VJgowh5vNnsdifYT6uVaf4pSo=
github.com/ergochat/readline v0.1.3/go.mod h1:o3ux9QLHLm77bq7hDB21UTm6HlV2++IPDMfIfKDuOgY=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
@ -79,6 +75,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.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 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/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.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.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@ -269,8 +267,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= 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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
@ -295,7 +293,6 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -361,6 +358,8 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=

View file

@ -12,15 +12,19 @@ import (
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
) )
type ContextBuilder struct { type ContextBuilder struct {
workspace string workspace string
skillsLoader *skills.SkillsLoader skillsLoader *skills.SkillsLoader
memory *MemoryStore memory *MemoryStore
toolDiscoveryBM25 bool
toolDiscoveryRegex bool
// Cache for system prompt to avoid rebuilding on every call. // Cache for system prompt to avoid rebuilding on every call.
// This fixes issue #607: repeated reprocessing of the entire context. // This fixes issue #607: repeated reprocessing of the entire context.
@ -41,6 +45,12 @@ type ContextBuilder struct {
skillFilesAtCache map[string]time.Time skillFilesAtCache map[string]time.Time
} }
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
cb.toolDiscoveryBM25 = useBM25
cb.toolDiscoveryRegex = useRegex
return cb
}
func getGlobalConfigDir() string { func getGlobalConfigDir() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" { if home := os.Getenv("PICOCLAW_HOME"); home != "" {
return home return home
@ -71,8 +81,11 @@ func NewContextBuilder(workspace string) *ContextBuilder {
func (cb *ContextBuilder) getIdentity() string { func (cb *ContextBuilder) getIdentity() string {
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
toolDiscovery := cb.getDiscoveryRule()
version := config.FormatVersion()
return fmt.Sprintf(`# picoclaw 🦞 return fmt.Sprintf(
`# picoclaw 🦞 (%s)
You are picoclaw, a helpful AI assistant. You are picoclaw, a helpful AI assistant.
@ -90,8 +103,29 @@ Your workspace is at: %s
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md 3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, 4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
%s`,
version, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolDiscovery)
}
func (cb *ContextBuilder) getDiscoveryRule() string {
if !cb.toolDiscoveryBM25 && !cb.toolDiscoveryRegex {
return ""
}
var toolNames []string
if cb.toolDiscoveryBM25 {
toolNames = append(toolNames, `"tool_search_tool_bm25"`)
}
if cb.toolDiscoveryRegex {
toolNames = append(toolNames, `"tool_search_tool_regex"`)
}
return fmt.Sprintf(
`5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`,
strings.Join(toolNames, " or "),
)
} }
func (cb *ContextBuilder) BuildSystemPrompt() string { func (cb *ContextBuilder) BuildSystemPrompt() string {
@ -505,10 +539,7 @@ func (cb *ContextBuilder) BuildMessages(
}) })
// Log preview of system prompt (avoid logging huge content) // Log preview of system prompt (avoid logging huge content)
preview := fullSystemPrompt preview := utils.Truncate(fullSystemPrompt, 500)
if len(preview) > 500 {
preview = preview[:500] + "... (truncated)"
}
logger.DebugCF("agent", "System prompt preview", logger.DebugCF("agent", "System prompt preview",
map[string]any{ map[string]any{
"preview": preview, "preview": preview,

View file

@ -1,6 +1,7 @@
package agent package agent
import ( import (
"context"
"fmt" "fmt"
"log" "log"
"os" "os"
@ -9,6 +10,7 @@ import (
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/memory"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/session"
@ -31,7 +33,7 @@ type AgentInstance struct {
SummarizeMessageThreshold int SummarizeMessageThreshold int
SummarizeTokenPercent int SummarizeTokenPercent int
Provider providers.LLMProvider Provider providers.LLMProvider
Sessions *session.SessionManager Sessions session.SessionStore
ContextBuilder *ContextBuilder ContextBuilder *ContextBuilder
Tools *tools.ToolRegistry Tools *tools.ToolRegistry
Subagents *config.SubagentsConfig Subagents *config.SubagentsConfig
@ -70,7 +72,8 @@ func NewAgentInstance(
toolsRegistry := tools.NewToolRegistry() toolsRegistry := tools.NewToolRegistry()
if cfg.Tools.IsToolEnabled("read_file") { if cfg.Tools.IsToolEnabled("read_file") {
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, allowReadPaths)) maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
} }
if cfg.Tools.IsToolEnabled("write_file") { if cfg.Tools.IsToolEnabled("write_file") {
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
@ -94,9 +97,13 @@ func NewAgentInstance(
} }
sessionsDir := filepath.Join(workspace, "sessions") sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir) sessions := initSessionStore(sessionsDir)
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 agentID := routing.DefaultAgentID
agentName := "" agentName := ""
@ -221,7 +228,7 @@ func NewAgentInstance(
SummarizeMessageThreshold: summarizeMessageThreshold, SummarizeMessageThreshold: summarizeMessageThreshold,
SummarizeTokenPercent: summarizeTokenPercent, SummarizeTokenPercent: summarizeTokenPercent,
Provider: provider, Provider: provider,
Sessions: sessionsManager, Sessions: sessions,
ContextBuilder: contextBuilder, ContextBuilder: contextBuilder,
Tools: toolsRegistry, Tools: toolsRegistry,
Subagents: subagents, Subagents: subagents,
@ -275,6 +282,39 @@ func compilePatterns(patterns []string) []*regexp.Regexp {
return compiled return compiled
} }
// Close releases resources held by the agent's session store.
func (a *AgentInstance) Close() error {
if a.Sessions != nil {
return a.Sessions.Close()
}
return nil
}
// initSessionStore creates the session persistence backend.
// It uses the JSONL store by default and auto-migrates legacy JSON sessions.
// Falls back to SessionManager if the JSONL store cannot be initialized or
// if migration fails (which indicates the store cannot write reliably).
func initSessionStore(dir string) session.SessionStore {
store, err := memory.NewJSONLStore(dir)
if err != nil {
log.Printf("memory: init store: %v; using json sessions", err)
return session.NewSessionManager(dir)
}
if n, merr := memory.MigrateFromJSON(context.Background(), dir, store); merr != nil {
// Migration failure means the store could not write data.
// Fall back to SessionManager to avoid a split state where
// some sessions are in JSONL and others remain in JSON.
log.Printf("memory: migration failed: %v; falling back to json sessions", merr)
store.Close()
return session.NewSessionManager(dir)
} else if n > 0 {
log.Printf("memory: migrated %d session(s) to jsonl", n)
}
return session.NewJSONLBackend(store)
}
func expandHome(path string) string { func expandHome(path string) string {
if path == "" { if path == "" {
return path return path

View file

@ -25,7 +25,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/media" "github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/routing"
@ -48,6 +47,10 @@ type AgentLoop struct {
mediaStore media.MediaStore mediaStore media.MediaStore
transcriber voice.Transcriber transcriber voice.Transcriber
cmdRegistry *commands.Registry cmdRegistry *commands.Registry
mcp mcpRuntime
mu sync.RWMutex
// Track active requests for safe provider cleanup
activeRequests sync.WaitGroup
} }
// processOptions configures how a message is processed // processOptions configures how a message is processed
@ -120,19 +123,21 @@ func registerSharedTools(
continue continue
} }
// Web tools
if cfg.Tools.IsToolEnabled("web") { if cfg.Tools.IsToolEnabled("web") {
searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{
BraveAPIKey: cfg.Tools.Web.Brave.APIKey, BraveAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Brave.APIKey, cfg.Tools.Web.Brave.APIKeys),
BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
BraveEnabled: cfg.Tools.Web.Brave.Enabled, BraveEnabled: cfg.Tools.Web.Brave.Enabled,
TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, TavilyAPIKeys: config.MergeAPIKeys(cfg.Tools.Web.Tavily.APIKey, cfg.Tools.Web.Tavily.APIKeys),
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, PerplexityAPIKeys: config.MergeAPIKeys(
cfg.Tools.Web.Perplexity.APIKey,
cfg.Tools.Web.Perplexity.APIKeys,
),
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL,
@ -238,71 +243,8 @@ func registerSharedTools(
func (al *AgentLoop) Run(ctx context.Context) error { func (al *AgentLoop) Run(ctx context.Context) error {
al.running.Store(true) al.running.Store(true)
// Initialize MCP servers for all agents if err := al.ensureMCPInitialized(ctx); err != nil {
if al.cfg.Tools.IsToolEnabled("mcp") { return err
mcpManager := mcp.NewManager()
// Ensure MCP connections are cleaned up on exit, regardless of initialization success
// This fixes resource leak when LoadFromMCPConfig partially succeeds then fails
defer func() {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}()
defaultAgent := al.registry.GetDefaultAgent()
var workspacePath string
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
} else {
workspacePath = al.cfg.WorkspacePath()
}
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(),
})
} else {
// 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)
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,
})
}
} }
for al.running.Load() { for al.running.Load() {
@ -340,7 +282,7 @@ func (al *AgentLoop) Run(ctx context.Context) error {
// If so, skip publishing to avoid duplicate messages to the user. // If so, skip publishing to avoid duplicate messages to the user.
// Use default agent's tools to check (message tool is shared). // Use default agent's tools to check (message tool is shared).
alreadySent := false alreadySent := false
defaultAgent := al.registry.GetDefaultAgent() defaultAgent := al.GetRegistry().GetDefaultAgent()
if defaultAgent != nil { if defaultAgent != nil {
if tool, ok := defaultAgent.Tools.Get("message"); ok { if tool, ok := defaultAgent.Tools.Get("message"); ok {
if mt, ok := tool.(*tools.MessageTool); ok { if mt, ok := tool.(*tools.MessageTool); ok {
@ -380,9 +322,26 @@ func (al *AgentLoop) Stop() {
al.running.Store(false) al.running.Store(false)
} }
// Close releases resources held by agent session stores. Call after Stop.
func (al *AgentLoop) Close() {
mcpManager := al.mcp.takeManager()
if mcpManager != nil {
if err := mcpManager.Close(); err != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": err.Error(),
})
}
}
al.GetRegistry().Close()
}
func (al *AgentLoop) RegisterTool(tool tools.Tool) { func (al *AgentLoop) RegisterTool(tool tools.Tool) {
for _, agentID := range al.registry.ListAgentIDs() { registry := al.GetRegistry()
if agent, ok := al.registry.GetAgent(agentID); ok { for _, agentID := range registry.ListAgentIDs() {
if agent, ok := registry.GetAgent(agentID); ok {
agent.Tools.Register(tool) agent.Tools.Register(tool)
} }
} }
@ -392,12 +351,123 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
al.channelManager = cm al.channelManager = cm
} }
// ReloadProviderAndConfig atomically swaps the provider and config with proper synchronization.
// It uses a context to allow timeout control from the caller.
// Returns an error if the reload fails or context is canceled.
func (al *AgentLoop) ReloadProviderAndConfig(
ctx context.Context,
provider providers.LLMProvider,
cfg *config.Config,
) error {
// Validate inputs
if provider == nil {
return fmt.Errorf("provider cannot be nil")
}
if cfg == nil {
return fmt.Errorf("config cannot be nil")
}
// Create new registry with updated config and provider
// Wrap in defer/recover to handle any panics gracefully
var registry *AgentRegistry
var panicErr error
done := make(chan struct{}, 1)
go func() {
defer func() {
if r := recover(); r != nil {
panicErr = fmt.Errorf("panic during registry creation: %v", r)
logger.ErrorCF("agent", "Panic during registry creation",
map[string]any{"panic": r})
}
close(done)
}()
registry = NewAgentRegistry(cfg, provider)
}()
// Wait for completion or context cancellation
select {
case <-done:
if registry == nil {
if panicErr != nil {
return fmt.Errorf("registry creation failed: %w", panicErr)
}
return fmt.Errorf("registry creation failed (nil result)")
}
case <-ctx.Done():
return fmt.Errorf("context canceled during registry creation: %w", ctx.Err())
}
// Check context again before proceeding
if err := ctx.Err(); err != nil {
return fmt.Errorf("context canceled after registry creation: %w", err)
}
// Ensure shared tools are re-registered on the new registry
registerSharedTools(cfg, al.bus, registry, provider)
// Atomically swap the config and registry under write lock
// This ensures readers see a consistent pair
al.mu.Lock()
oldRegistry := al.registry
// Store new values
al.cfg = cfg
al.registry = registry
// Also update fallback chain with new config
al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker())
al.mu.Unlock()
// Close old provider after releasing the lock
// This prevents blocking readers while closing
if oldProvider, ok := extractProvider(oldRegistry); ok {
if stateful, ok := oldProvider.(providers.StatefulProvider); ok {
// Give in-flight requests a moment to complete
// Use a reasonable timeout that balances cleanup vs resource usage
select {
case <-time.After(100 * time.Millisecond):
stateful.Close()
case <-ctx.Done():
// Context canceled, close immediately but log warning
logger.WarnCF("agent", "Context canceled during provider cleanup, forcing close",
map[string]any{"error": ctx.Err()})
stateful.Close()
}
}
}
logger.InfoCF("agent", "Provider and config reloaded successfully",
map[string]any{
"model": cfg.Agents.Defaults.GetModelName(),
})
return nil
}
// GetRegistry returns the current registry (thread-safe)
func (al *AgentLoop) GetRegistry() *AgentRegistry {
al.mu.RLock()
defer al.mu.RUnlock()
return al.registry
}
// GetConfig returns the current config (thread-safe)
func (al *AgentLoop) GetConfig() *config.Config {
al.mu.RLock()
defer al.mu.RUnlock()
return al.cfg
}
// SetMediaStore injects a MediaStore for media lifecycle management. // SetMediaStore injects a MediaStore for media lifecycle management.
func (al *AgentLoop) SetMediaStore(s media.MediaStore) { func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
al.mediaStore = s al.mediaStore = s
// Propagate store to send_file tools in all agents. // Propagate store to send_file tools in all agents.
al.registry.ForEachTool("send_file", func(t tools.Tool) { registry := al.GetRegistry()
registry.ForEachTool("send_file", func(t tools.Tool) {
if sf, ok := t.(*tools.SendFileTool); ok { if sf, ok := t.(*tools.SendFileTool); ok {
sf.SetMediaStore(s) sf.SetMediaStore(s)
} }
@ -413,9 +483,10 @@ var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and // transcribeAudioInMessage resolves audio media refs, transcribes them, and
// replaces audio annotations in msg.Content with the transcribed text. // replaces audio annotations in msg.Content with the transcribed text.
func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.InboundMessage) bus.InboundMessage { // 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 { if al.transcriber == nil || al.mediaStore == nil || len(msg.Media) == 0 {
return msg return msg, false
} }
// Transcribe each audio media ref in order. // Transcribe each audio media ref in order.
@ -439,9 +510,11 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
} }
if len(transcriptions) == 0 { if len(transcriptions) == 0 {
return msg return msg, false
} }
al.sendTranscriptionFeedback(ctx, msg.Channel, msg.ChatID, msg.MessageID, transcriptions)
// Replace audio annotations sequentially with transcriptions. // Replace audio annotations sequentially with transcriptions.
idx := 0 idx := 0
newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string {
@ -459,7 +532,48 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou
} }
msg.Content = newContent msg.Content = newContent
return msg 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") // inferMediaType determines the media type ("image", "audio", "video", "file")
@ -521,6 +635,10 @@ func (al *AgentLoop) ProcessDirectWithChannel(
ctx context.Context, ctx context.Context,
content, sessionKey, channel, chatID string, content, sessionKey, channel, chatID string,
) (string, error) { ) (string, error) {
if err := al.ensureMCPInitialized(ctx); err != nil {
return "", err
}
msg := bus.InboundMessage{ msg := bus.InboundMessage{
Channel: channel, Channel: channel,
SenderID: "cron", SenderID: "cron",
@ -538,7 +656,7 @@ func (al *AgentLoop) ProcessHeartbeat(
ctx context.Context, ctx context.Context,
content, channel, chatID string, content, channel, chatID string,
) (string, error) { ) (string, error) {
agent := al.registry.GetDefaultAgent() agent := al.GetRegistry().GetDefaultAgent()
if agent == nil { if agent == nil {
return "", fmt.Errorf("no default agent for heartbeat") return "", fmt.Errorf("no default agent for heartbeat")
} }
@ -573,7 +691,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}, },
) )
msg = al.transcribeAudioInMessage(ctx, msg) var hadAudio bool
msg, hadAudio = al.transcribeAudioInMessage(ctx, msg)
// For audio messages the placeholder was deferred by the channel.
// Now that transcription (and optional feedback) is done, send it.
if hadAudio && al.channelManager != nil {
al.channelManager.SendPlaceholder(ctx, msg.Channel, msg.ChatID)
}
// Route system messages to processSystemMessage // Route system messages to processSystemMessage
if msg.Channel == "system" { if msg.Channel == "system" {
@ -581,15 +706,6 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
} }
route, agent, routeErr := al.resolveMessageRoute(msg) route, agent, routeErr := al.resolveMessageRoute(msg)
// Commands are checked before requiring a successful route.
// Global commands (/help, /show, /switch) work even when routing fails;
// context-dependent commands check their own Runtime fields and report
// "unavailable" when the required capability is nil.
if response, handled := al.handleCommand(ctx, msg, agent); handled {
return response, nil
}
if routeErr != nil { if routeErr != nil {
return "", routeErr return "", routeErr
} }
@ -615,7 +731,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
"route_channel": route.Channel, "route_channel": route.Channel,
}) })
return al.runAgentLoop(ctx, agent, processOptions{ opts := processOptions{
SessionKey: sessionKey, SessionKey: sessionKey,
Channel: msg.Channel, Channel: msg.Channel,
ChatID: msg.ChatID, ChatID: msg.ChatID,
@ -624,11 +740,20 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
DefaultResponse: defaultResponse, DefaultResponse: defaultResponse,
EnableSummary: true, EnableSummary: true,
SendResponse: false, SendResponse: false,
}) }
// context-dependent commands check their own Runtime fields and report
// "unavailable" when the required capability is nil.
if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled {
return response, nil
}
return al.runAgentLoop(ctx, agent, opts)
} }
func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) {
route := al.registry.ResolveRoute(routing.RouteInput{ registry := al.GetRegistry()
route := registry.ResolveRoute(routing.RouteInput{
Channel: msg.Channel, Channel: msg.Channel,
AccountID: inboundMetadata(msg, metadataKeyAccountID), AccountID: inboundMetadata(msg, metadataKeyAccountID),
Peer: extractPeer(msg), Peer: extractPeer(msg),
@ -637,9 +762,9 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv
TeamID: inboundMetadata(msg, metadataKeyTeamID), TeamID: inboundMetadata(msg, metadataKeyTeamID),
}) })
agent, ok := al.registry.GetAgent(route.AgentID) agent, ok := registry.GetAgent(route.AgentID)
if !ok { if !ok {
agent = al.registry.GetDefaultAgent() agent = registry.GetDefaultAgent()
} }
if agent == nil { if agent == nil {
return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID) return routing.ResolvedRoute{}, nil, fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
@ -701,7 +826,7 @@ func (al *AgentLoop) processSystemMessage(
} }
// Use default agent for system messages // Use default agent for system messages
agent := al.registry.GetDefaultAgent() agent := al.GetRegistry().GetDefaultAgent()
if agent == nil { if agent == nil {
return "", fmt.Errorf("no default agent for system message") return "", fmt.Errorf("no default agent for system message")
} }
@ -756,8 +881,9 @@ func (al *AgentLoop) runAgentLoop(
opts.ChatID, opts.ChatID,
) )
// Resolve media:// refs to base64 data URLs (streaming) // Resolve media:// refs: images→base64 data URLs, non-images→local paths in content
maxMediaSize := al.cfg.Agents.Defaults.GetMaxMediaSize() cfg := al.GetConfig()
maxMediaSize := cfg.Agents.Defaults.GetMaxMediaSize()
messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize)
// 2. Save user message to session // 2. Save user message to session
@ -935,6 +1061,9 @@ func (al *AgentLoop) runLLMIteration(
} }
callLLM := func() (*providers.LLMResponse, error) { callLLM := func() (*providers.LLMResponse, error) {
al.activeRequests.Add(1)
defer al.activeRequests.Done()
if len(activeCandidates) > 1 && al.fallback != nil { if len(activeCandidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute( fbResult, fbErr := al.fallback.Execute(
ctx, ctx,
@ -1033,6 +1162,7 @@ func (al *AgentLoop) runLLMIteration(
map[string]any{ map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
"iteration": iteration, "iteration": iteration,
"model": activeModel,
"error": err.Error(), "error": err.Error(),
}) })
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
@ -1255,6 +1385,17 @@ func (al *AgentLoop) runLLMIteration(
// Save tool result message to session // Save tool result message to session
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg) agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
} }
// Tick down TTL of discovered tools after processing tool results.
// Only reached when tool calls were made (the loop continues);
// the break on no-tool-call responses skips this.
// NOTE: This is safe because processMessage is sequential per agent.
// If per-agent concurrency is added, TTL consistency between
// ToProviderDefs and Get must be re-evaluated.
agent.Tools.TickTTL()
logger.DebugCF("agent", "TTL tick after tool execution", map[string]any{
"agent_id": agent.ID, "iteration": iteration,
})
} }
return finalContent, iteration, nil return finalContent, iteration, nil
@ -1373,7 +1514,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
func (al *AgentLoop) GetStartupInfo() map[string]any { func (al *AgentLoop) GetStartupInfo() map[string]any {
info := make(map[string]any) info := make(map[string]any)
agent := al.registry.GetDefaultAgent() registry := al.GetRegistry()
agent := registry.GetDefaultAgent()
if agent == nil { if agent == nil {
return info return info
} }
@ -1390,8 +1532,8 @@ func (al *AgentLoop) GetStartupInfo() map[string]any {
// Agents info // Agents info
info["agents"] = map[string]any{ info["agents"] = map[string]any{
"count": len(al.registry.ListAgentIDs()), "count": len(registry.ListAgentIDs()),
"ids": al.registry.ListAgentIDs(), "ids": registry.ListAgentIDs(),
} }
return info return info
@ -1492,10 +1634,20 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
return return
} }
const (
maxSummarizationMessages = 10
llmMaxRetries = 3
llmTemperature = 0.3
fallbackMaxContentLength = 200
)
// Multi-Part Summarization // Multi-Part Summarization
var finalSummary string var finalSummary string
if len(validMessages) > 10 { if len(validMessages) > maxSummarizationMessages {
mid := len(validMessages) / 2 mid := len(validMessages) / 2
mid = al.findNearestUserMessage(validMessages, mid)
part1 := validMessages[:mid] part1 := validMessages[:mid]
part2 := validMessages[mid:] part2 := validMessages[mid:]
@ -1507,18 +1659,9 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
s1, s1,
s2, s2,
) )
resp, err := agent.Provider.Chat(
ctx, resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries)
[]providers.Message{{Role: "user", Content: mergePrompt}}, if err == nil && resp.Content != "" {
nil,
agent.Model,
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
"prompt_cache_key": agent.ID,
},
)
if err == nil {
finalSummary = resp.Content finalSummary = resp.Content
} else { } else {
finalSummary = s1 + " " + s2 finalSummary = s1 + " " + s2
@ -1538,6 +1681,73 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
} }
} }
// findNearestUserMessage finds the nearest user message to the given index.
// It searches backward first, then forward if no user message is found.
func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int {
originalMid := mid
for mid > 0 && messages[mid].Role != "user" {
mid--
}
if messages[mid].Role == "user" {
return mid
}
mid = originalMid
for mid < len(messages) && messages[mid].Role != "user" {
mid++
}
if mid < len(messages) {
return mid
}
return originalMid
}
// retryLLMCall calls the LLM with retry logic.
func (al *AgentLoop) retryLLMCall(
ctx context.Context,
agent *AgentInstance,
prompt string,
maxRetries int,
) (*providers.LLMResponse, error) {
const (
llmTemperature = 0.3
)
var resp *providers.LLMResponse
var err error
for attempt := 0; attempt < maxRetries; attempt++ {
al.activeRequests.Add(1)
resp, err = func() (*providers.LLMResponse, error) {
defer al.activeRequests.Done()
return agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: prompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": agent.MaxTokens,
"temperature": llmTemperature,
"prompt_cache_key": agent.ID,
},
)
}()
if err == nil && resp != nil && resp.Content != "" {
return resp, nil
}
if attempt < maxRetries-1 {
time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond)
}
}
return resp, err
}
// summarizeBatch summarizes a batch of messages. // summarizeBatch summarizes a batch of messages.
func (al *AgentLoop) summarizeBatch( func (al *AgentLoop) summarizeBatch(
ctx context.Context, ctx context.Context,
@ -1545,6 +1755,13 @@ func (al *AgentLoop) summarizeBatch(
batch []providers.Message, batch []providers.Message,
existingSummary string, existingSummary string,
) (string, error) { ) (string, error) {
const (
llmMaxRetries = 3
llmTemperature = 0.3
fallbackMinContentLength = 200
fallbackMaxContentPercent = 10
)
var sb strings.Builder var sb strings.Builder
sb.WriteString( sb.WriteString(
"Provide a concise summary of this conversation segment, preserving core context and key points.\n", "Provide a concise summary of this conversation segment, preserving core context and key points.\n",
@ -1560,21 +1777,40 @@ func (al *AgentLoop) summarizeBatch(
} }
prompt := sb.String() prompt := sb.String()
response, err := agent.Provider.Chat( response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries)
ctx, if err == nil && response.Content != "" {
[]providers.Message{{Role: "user", Content: prompt}}, return strings.TrimSpace(response.Content), nil
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
var fallback strings.Builder
fallback.WriteString("Conversation summary: ")
for i, m := range batch {
if i > 0 {
fallback.WriteString(" | ")
}
content := strings.TrimSpace(m.Content)
runes := []rune(content)
if len(runes) == 0 {
fallback.WriteString(fmt.Sprintf("%s: ", m.Role))
continue
}
keepLength := len(runes) * fallbackMaxContentPercent / 100
if keepLength < fallbackMinContentLength {
keepLength = fallbackMinContentLength
}
if keepLength > len(runes) {
keepLength = len(runes)
}
content = string(runes[:keepLength])
if keepLength < len(runes) {
content += "..."
}
fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content))
}
return fallback.String(), nil
} }
// estimateTokens estimates the number of tokens in a message list. // estimateTokens estimates the number of tokens in a message list.
@ -1593,6 +1829,7 @@ func (al *AgentLoop) handleCommand(
ctx context.Context, ctx context.Context,
msg bus.InboundMessage, msg bus.InboundMessage,
agent *AgentInstance, agent *AgentInstance,
opts *processOptions,
) (string, bool) { ) (string, bool) {
if !commands.HasCommandPrefix(msg.Content) { if !commands.HasCommandPrefix(msg.Content) {
return "", false return "", false
@ -1602,7 +1839,7 @@ func (al *AgentLoop) handleCommand(
return "", false return "", false
} }
rt := al.buildCommandsRuntime(agent) rt := al.buildCommandsRuntime(agent, opts)
executor := commands.NewExecutor(al.cmdRegistry, rt) executor := commands.NewExecutor(al.cmdRegistry, rt)
var commandReply string var commandReply string
@ -1631,10 +1868,12 @@ func (al *AgentLoop) handleCommand(
} }
} }
func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtime { func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime {
registry := al.GetRegistry()
cfg := al.GetConfig()
rt := &commands.Runtime{ rt := &commands.Runtime{
Config: al.cfg, Config: cfg,
ListAgentIDs: al.registry.ListAgentIDs, ListAgentIDs: registry.ListAgentIDs,
ListDefinitions: al.cmdRegistry.Definitions, ListDefinitions: al.cmdRegistry.Definitions,
GetEnabledChannels: func() []string { GetEnabledChannels: func() []string {
if al.channelManager == nil { if al.channelManager == nil {
@ -1654,13 +1893,27 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance) *commands.Runtim
} }
if agent != nil { if agent != nil {
rt.GetModelInfo = func() (string, string) { rt.GetModelInfo = func() (string, string) {
return agent.Model, al.cfg.Agents.Defaults.Provider return agent.Model, cfg.Agents.Defaults.Provider
} }
rt.SwitchModel = func(value string) (string, error) { rt.SwitchModel = func(value string) (string, error) {
oldModel := agent.Model oldModel := agent.Model
agent.Model = value agent.Model = value
return oldModel, nil return oldModel, nil
} }
rt.ClearHistory = func() error {
if opts == nil {
return fmt.Errorf("process options not available")
}
if agent.Sessions == nil {
return fmt.Errorf("sessions not initialized for agent")
}
agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0))
agent.Sessions.SetSummary(opts.SessionKey, "")
agent.Sessions.Save(opts.SessionKey)
return nil
}
} }
return rt return rt
} }
@ -1704,3 +1957,16 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
} }
return &routing.RoutePeer{Kind: parentKind, ID: parentID} return &routing.RoutePeer{Kind: parentKind, ID: parentID}
} }
// Helper to extract provider from registry for cleanup
func extractProvider(registry *AgentRegistry) (providers.LLMProvider, bool) {
if registry == nil {
return nil, false
}
// Get any agent to access the provider
defaultAgent := registry.GetDefaultAgent()
if defaultAgent == nil {
return nil, false
}
return defaultAgent.Provider, true
}

200
pkg/agent/loop_mcp.go Normal file
View file

@ -0,0 +1,200 @@
// 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
}
if al.cfg.Tools.MCP.Servers == nil || len(al.cfg.Tools.MCP.Servers) == 0 {
logger.WarnCF("agent", "MCP is enabled but no servers are configured, skipping MCP initialization", nil)
return nil
}
findValidServer := false
for _, serverCfg := range al.cfg.Tools.MCP.Servers {
if serverCfg.Enabled {
findValidServer = true
}
}
if !findValidServer {
logger.WarnCF("agent", "MCP is enabled but no valid servers are configured, skipping MCP initialization", nil)
return nil
}
al.mcp.initOnce.Do(func() {
mcpManager := mcp.NewManager()
defaultAgent := al.registry.GetDefaultAgent()
workspacePath := al.cfg.WorkspacePath()
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
"error": err.Error(),
})
if closeErr := mcpManager.Close(); closeErr != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": closeErr.Error(),
})
}
return
}
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
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()
}

View file

@ -20,9 +20,10 @@ import (
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
) )
// resolveMediaRefs replaces media:// refs in message Media fields with base64 data URLs. // resolveMediaRefs resolves media:// refs in messages.
// Uses streaming base64 encoding (file handle → encoder → buffer) to avoid holding // Images are base64-encoded into the Media array for multimodal LLMs.
// both raw bytes and encoded string in memory simultaneously. // Non-image files (documents, audio, video) have their local path injected
// into Content so the agent can access them via file tools like read_file.
// Returns a new slice; original messages are not mutated. // Returns a new slice; original messages are not mutated.
func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message { func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxSize int) []providers.Message {
if store == nil { if store == nil {
@ -38,6 +39,8 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
} }
resolved := make([]string, 0, len(m.Media)) resolved := make([]string, 0, len(m.Media))
var pathTags []string
for _, ref := range m.Media { for _, ref := range m.Media {
if !strings.HasPrefix(ref, "media://") { if !strings.HasPrefix(ref, "media://") {
resolved = append(resolved, ref) resolved = append(resolved, ref)
@ -61,62 +64,117 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS
}) })
continue continue
} }
if info.Size() > int64(maxSize) {
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
"path": localPath,
"size": info.Size(),
"max_size": maxSize,
})
continue
}
// Determine MIME type: prefer metadata, fallback to magic-bytes detection mime := detectMIME(localPath, meta)
mime := meta.ContentType
if mime == "" { if strings.HasPrefix(mime, "image/") {
kind, ftErr := filetype.MatchFile(localPath) dataURL := encodeImageToDataURL(localPath, mime, info, maxSize)
if ftErr != nil || kind == filetype.Unknown { if dataURL != "" {
logger.WarnCF("agent", "Unknown media type, skipping", map[string]any{ resolved = append(resolved, dataURL)
"path": localPath,
})
continue
} }
mime = kind.MIME.Value
}
// Streaming base64: open file → base64 encoder → buffer
// Peak memory: ~1.33x file size (buffer only, no raw bytes copy)
f, err := os.Open(localPath)
if err != nil {
logger.WarnCF("agent", "Failed to open media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
continue continue
} }
prefix := "data:" + mime + ";base64," pathTags = append(pathTags, buildPathTag(mime, localPath))
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
var buf bytes.Buffer
buf.Grow(len(prefix) + encodedLen)
buf.WriteString(prefix)
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
if _, err := io.Copy(encoder, f); err != nil {
f.Close()
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
continue
}
encoder.Close()
f.Close()
resolved = append(resolved, buf.String())
} }
result[i].Media = resolved result[i].Media = resolved
if len(pathTags) > 0 {
result[i].Content = injectPathTags(result[i].Content, pathTags)
}
} }
return result return result
} }
// detectMIME determines the MIME type from metadata or magic-bytes detection.
// Returns empty string if detection fails.
func detectMIME(localPath string, meta media.MediaMeta) string {
if meta.ContentType != "" {
return meta.ContentType
}
kind, err := filetype.MatchFile(localPath)
if err != nil || kind == filetype.Unknown {
return ""
}
return kind.MIME.Value
}
// encodeImageToDataURL base64-encodes an image file into a data URL.
// Returns empty string if the file exceeds maxSize or encoding fails.
func encodeImageToDataURL(localPath, mime string, info os.FileInfo, maxSize int) string {
if info.Size() > int64(maxSize) {
logger.WarnCF("agent", "Media file too large, skipping", map[string]any{
"path": localPath,
"size": info.Size(),
"max_size": maxSize,
})
return ""
}
f, err := os.Open(localPath)
if err != nil {
logger.WarnCF("agent", "Failed to open media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
return ""
}
defer f.Close()
prefix := "data:" + mime + ";base64,"
encodedLen := base64.StdEncoding.EncodedLen(int(info.Size()))
var buf bytes.Buffer
buf.Grow(len(prefix) + encodedLen)
buf.WriteString(prefix)
encoder := base64.NewEncoder(base64.StdEncoding, &buf)
if _, err := io.Copy(encoder, f); err != nil {
logger.WarnCF("agent", "Failed to encode media file", map[string]any{
"path": localPath,
"error": err.Error(),
})
return ""
}
encoder.Close()
return buf.String()
}
// buildPathTag creates a structured tag exposing the local file path.
// Tag type is derived from MIME: [audio:/path], [video:/path], or [file:/path].
func buildPathTag(mime, localPath string) string {
switch {
case strings.HasPrefix(mime, "audio/"):
return "[audio:" + localPath + "]"
case strings.HasPrefix(mime, "video/"):
return "[video:" + localPath + "]"
default:
return "[file:" + localPath + "]"
}
}
// injectPathTags replaces generic media tags in content with path-bearing versions,
// or appends if no matching generic tag is found.
func injectPathTags(content string, tags []string) string {
for _, tag := range tags {
var generic string
switch {
case strings.HasPrefix(tag, "[audio:"):
generic = "[audio]"
case strings.HasPrefix(tag, "[video:"):
generic = "[video]"
case strings.HasPrefix(tag, "[file:"):
generic = "[file]"
}
if generic != "" && strings.Contains(content, generic) {
content = strings.Replace(content, generic, tag, 1)
} else if content == "" {
content = tag
} else {
content += " " + tag
}
}
return content
}

View file

@ -770,6 +770,63 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
} }
} }
// TestProcessDirectWithChannel_TriggersMCPInitialization verifies that
// ProcessDirectWithChannel triggers MCP initialization when MCP is enabled.
// Note: Manager is only initialized when at least one MCP server is configured
// and successfully connected.
func TestProcessDirectWithChannel_TriggersMCPInitialization(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Test with MCP enabled but no servers - should not initialize manager
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
Tools: config.ToolsConfig{
MCP: config.MCPConfig{
ToolConfig: config.ToolConfig{
Enabled: true,
},
// No servers configured - manager should not be initialized
},
},
}
msgBus := bus.NewMessageBus()
provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider)
defer al.Close()
if al.mcp.hasManager() {
t.Fatal("expected MCP manager to be nil before first direct processing")
}
_, err = al.ProcessDirectWithChannel(
context.Background(),
"hello",
"session-1",
"cli",
"direct",
)
if err != nil {
t.Fatalf("ProcessDirectWithChannel failed: %v", err)
}
// Manager should not be initialized when no servers are configured
if al.mcp.hasManager() {
t.Fatal("expected MCP manager to be nil when no servers are configured")
}
}
func TestTargetReasoningChannelID_AllChannels(t *testing.T) { func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil { if err != nil {
@ -1038,7 +1095,7 @@ func TestResolveMediaRefs_SkipsOversizedFile(t *testing.T) {
} }
} }
func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) { func TestResolveMediaRefs_UnknownTypeInjectsPath(t *testing.T) {
store := media.NewFileMediaStore() store := media.NewFileMediaStore()
dir := t.TempDir() dir := t.TempDir()
@ -1054,7 +1111,11 @@ func TestResolveMediaRefs_SkipsUnknownType(t *testing.T) {
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize) result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 0 { if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (unknown type), got %d", len(result[0].Media)) t.Fatalf("expected 0 media entries, got %d", len(result[0].Media))
}
expected := "hi [file:" + txtPath + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
} }
} }
@ -1116,3 +1177,144 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) {
t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30])
} }
} }
func TestResolveMediaRefs_PDFInjectsFilePath(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
pdfPath := filepath.Join(dir, "report.pdf")
// PDF magic bytes
os.WriteFile(pdfPath, []byte("%PDF-1.4 test content"), 0o644)
ref, _ := store.Store(pdfPath, media.MediaMeta{ContentType: "application/pdf"}, "test")
messages := []providers.Message{
{Role: "user", Content: "report.pdf [file]", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media (non-image), got %d", len(result[0].Media))
}
expected := "report.pdf [file:" + pdfPath + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
}
}
func TestResolveMediaRefs_AudioInjectsAudioPath(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
oggPath := filepath.Join(dir, "voice.ogg")
os.WriteFile(oggPath, []byte("fake audio"), 0o644)
ref, _ := store.Store(oggPath, media.MediaMeta{ContentType: "audio/ogg"}, "test")
messages := []providers.Message{
{Role: "user", Content: "voice.ogg [audio]", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media, got %d", len(result[0].Media))
}
expected := "voice.ogg [audio:" + oggPath + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
}
}
func TestResolveMediaRefs_VideoInjectsVideoPath(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
mp4Path := filepath.Join(dir, "clip.mp4")
os.WriteFile(mp4Path, []byte("fake video"), 0o644)
ref, _ := store.Store(mp4Path, media.MediaMeta{ContentType: "video/mp4"}, "test")
messages := []providers.Message{
{Role: "user", Content: "clip.mp4 [video]", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 0 {
t.Fatalf("expected 0 media, got %d", len(result[0].Media))
}
expected := "clip.mp4 [video:" + mp4Path + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
}
}
func TestResolveMediaRefs_NoGenericTagAppendsPath(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
csvPath := filepath.Join(dir, "data.csv")
os.WriteFile(csvPath, []byte("a,b,c"), 0o644)
ref, _ := store.Store(csvPath, media.MediaMeta{ContentType: "text/csv"}, "test")
messages := []providers.Message{
{Role: "user", Content: "here is my data", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
expected := "here is my data [file:" + csvPath + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
}
}
func TestResolveMediaRefs_EmptyContentGetsPathTag(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
docPath := filepath.Join(dir, "doc.docx")
os.WriteFile(docPath, []byte("fake docx"), 0o644)
docxMIME := "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
ref, _ := store.Store(docPath, media.MediaMeta{ContentType: docxMIME}, "test")
messages := []providers.Message{
{Role: "user", Content: "", Media: []string{ref}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
expected := "[file:" + docPath + "]"
if result[0].Content != expected {
t.Fatalf("expected content %q, got %q", expected, result[0].Content)
}
}
func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) {
store := media.NewFileMediaStore()
dir := t.TempDir()
pngPath := filepath.Join(dir, "photo.png")
pngHeader := []byte{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02,
0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xDE,
}
os.WriteFile(pngPath, pngHeader, 0o644)
imgRef, _ := store.Store(pngPath, media.MediaMeta{}, "test")
pdfPath := filepath.Join(dir, "report.pdf")
os.WriteFile(pdfPath, []byte("%PDF-1.4 test"), 0o644)
fileRef, _ := store.Store(pdfPath, media.MediaMeta{ContentType: "application/pdf"}, "test")
messages := []providers.Message{
{Role: "user", Content: "check these [file]", Media: []string{imgRef, fileRef}},
}
result := resolveMediaRefs(messages, store, config.DefaultMaxMediaSize)
if len(result[0].Media) != 1 {
t.Fatalf("expected 1 media (image only), got %d", len(result[0].Media))
}
if !strings.HasPrefix(result[0].Media[0], "data:image/png;base64,") {
t.Fatal("expected image to be base64 encoded")
}
expectedContent := "check these [file:" + pdfPath + "]"
if result[0].Content != expectedContent {
t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content)
}
}

View file

@ -114,6 +114,18 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
} }
} }
// 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. // GetDefaultAgent returns the default agent instance.
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance { func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
r.mu.RLock() r.mu.RLock()

View file

@ -30,9 +30,10 @@ type InboundMessage struct {
} }
type OutboundMessage struct { type OutboundMessage struct {
Channel string `json:"channel"` Channel string `json:"channel"`
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Content string `json:"content"` Content string `json:"content"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
} }
// MediaPart describes a single media attachment to send. // MediaPart describes a single media attachment to send.

View file

@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/binary" "encoding/binary"
"encoding/hex" "encoding/hex"
"regexp"
"strconv" "strconv"
"strings" "strings"
"sync/atomic" "sync/atomic"
@ -32,6 +33,9 @@ func init() {
uniqueIDPrefix = hex.EncodeToString(b[:]) 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. // 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 // 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. // cryptographically secure — it must not be used in contexts where unpredictability matters.
@ -284,10 +288,15 @@ func (c *BaseChannel) HandleMessage(
c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo) c.placeholderRecorder.RecordReactionUndo(c.name, chatID, undo)
} }
} }
// Placeholder — independent pipeline // Placeholder — independent pipeline.
if pc, ok := c.owner.(PlaceholderCapable); ok { // Skip when the message contains audio: the agent will send the
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" { // placeholder after transcription completes, so the user sees
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID) // "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)
}
} }
} }
} }

View file

@ -10,6 +10,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client" "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/bus"
"github.com/sipeed/picoclaw/pkg/channels" "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") 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, base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000), channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),

View file

@ -45,6 +45,14 @@ type DiscordChannel struct {
} }
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) { 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) session, err := discordgo.New("Bot " + cfg.Token)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err) 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 nil
} }
return c.sendChunk(ctx, channelID, msg.Content) return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
} }
// SendMedia implements the channels.MediaSender interface. // SendMedia implements the channels.MediaSender interface.
@ -259,14 +267,29 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
return msg.ID, nil 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 // Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel() defer cancel()
done := make(chan error, 1) done := make(chan error, 1)
go func() { 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 done <- err
}() }()

View file

@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"math/rand"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@ -195,18 +196,30 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
} }
// ReactToMessage implements channels.ReactionCapable. // ReactToMessage implements channels.ReactionCapable.
// Adds an "Pin" reaction and returns an undo function to remove it. // Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) { func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
// Get emoji list from config
emojiList := c.config.RandomReactionEmoji
var chosenEmoji string
if len(emojiList) == 0 {
// Default to "Pin" if no config
chosenEmoji = "Pin"
} else {
idx := rand.Intn(len(emojiList))
chosenEmoji = emojiList[idx]
}
req := larkim.NewCreateMessageReactionReqBuilder(). req := larkim.NewCreateMessageReactionReqBuilder().
MessageId(messageID). MessageId(messageID).
Body(larkim.NewCreateMessageReactionReqBodyBuilder(). Body(larkim.NewCreateMessageReactionReqBodyBuilder().
ReactionType(larkim.NewEmojiBuilder().EmojiType("Pin").Build()). ReactionType(larkim.NewEmojiBuilder().EmojiType(chosenEmoji).Build()).
Build()). Build()).
Build() Build()
resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req) resp, err := c.client.Im.V1.MessageReaction.Create(ctx, req)
if err != nil { if err != nil {
logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{ logger.ErrorCF("feishu", "Failed to add reaction", map[string]any{
"emoji": chosenEmoji,
"message_id": messageID, "message_id": messageID,
"error": err.Error(), "error": err.Error(),
}) })
@ -214,6 +227,7 @@ func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID st
} }
if !resp.Success() { if !resp.Success() {
logger.ErrorCF("feishu", "Reaction API error", map[string]any{ logger.ErrorCF("feishu", "Reaction API error", map[string]any{
"emoji": chosenEmoji,
"message_id": messageID, "message_id": messageID,
"code": resp.Code, "code": resp.Code,
"msg": resp.Msg, "msg": resp.Msg,

View file

@ -32,6 +32,10 @@ const (
lineBotInfoEndpoint = lineAPIBase + "/info" lineBotInfoEndpoint = lineAPIBase + "/info"
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start" lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
lineReplyTokenMaxAge = 25 * time.Second 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 { type replyTokenEntry struct {
@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
if err != nil { if err != nil {
logger.ErrorCF("line", "Failed to read request body", map[string]any{ logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(), "error": err.Error(),
@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Bad request", http.StatusBadRequest) http.Error(w, "Bad request", http.StatusBadRequest)
return 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") signature := r.Header.Get("X-Line-Signature")
if !c.verifySignature(body, signature) { if !c.verifySignature(body, signature) {

View file

@ -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)
}
}

View file

@ -61,7 +61,9 @@ var channelRateConfig = map[string]float64{
"telegram": 20, "telegram": 20,
"discord": 1, "discord": 1,
"slack": 1, "slack": 1,
"matrix": 2,
"line": 10, "line": 10,
"qq": 5,
"irc": 2, "irc": 2,
} }
@ -100,11 +102,37 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()}) 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. // RecordTypingStop registers a typing stop function for later invocation.
// Implements PlaceholderRecorder. // Implements PlaceholderRecorder.
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) { func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
key := channel + ":" + chatID key := channel + ":" + chatID
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()}) 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()
}
}
} }
// RecordReactionUndo registers a reaction undo function for later invocation. // RecordReactionUndo registers a reaction undo function for later invocation.
@ -244,6 +272,13 @@ func (m *Manager) initChannels() error {
m.initChannel("slack", "Slack") m.initChannel("slack", "Slack")
} }
if m.config.Channels.Matrix.Enabled &&
m.config.Channels.Matrix.Homeserver != "" &&
m.config.Channels.Matrix.UserID != "" &&
m.config.Channels.Matrix.AccessToken != "" {
m.initChannel("matrix", "Matrix")
}
if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" { if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
m.initChannel("line", "LINE") m.initChannel("line", "LINE")
} }
@ -804,6 +839,39 @@ func (m *Manager) UnregisterChannel(name string) {
delete(m.channels, name) delete(m.channels, name)
} }
// SendMessage sends an outbound message synchronously through the channel
// worker's rate limiter and retry logic. It blocks until the message is
// delivered (or all retries are exhausted), which preserves ordering when
// a subsequent operation depends on the message having been sent.
func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
m.mu.RLock()
_, exists := m.channels[msg.Channel]
w, wExists := m.workers[msg.Channel]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("channel %s not found", msg.Channel)
}
if !wExists || w == nil {
return fmt.Errorf("channel %s has no active worker", msg.Channel)
}
maxLen := 0
if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength()
}
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
for _, chunk := range SplitMessage(msg.Content, maxLen) {
chunkMsg := msg
chunkMsg.Content = chunk
m.sendWithRetry(ctx, msg.Channel, w, chunkMsg)
}
} else {
m.sendWithRetry(ctx, msg.Channel, w, msg)
}
return nil
}
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error { func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
m.mu.RLock() m.mu.RLock()
_, exists := m.channels[channelName] _, exists := m.channels[channelName]

View file

@ -17,16 +17,32 @@ import (
// mockChannel is a test double that delegates Send to a configurable function. // mockChannel is a test double that delegates Send to a configurable function.
type mockChannel struct { type mockChannel struct {
BaseChannel 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 { func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
m.sentMessages = append(m.sentMessages, msg)
return m.sendFn(ctx, msg) return m.sendFn(ctx, msg)
} }
func (m *mockChannel) Start(ctx context.Context) error { return nil } func (m *mockChannel) Start(ctx context.Context) error { return nil }
func (m *mockChannel) Stop(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. // newTestManager creates a minimal Manager suitable for unit tests.
func newTestManager() *Manager { func newTestManager() *Manager {
return &Manager{ return &Manager{
@ -600,6 +616,37 @@ func TestRecordTypingStop_ConcurrentSafe(t *testing.T) {
wg.Wait() 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) { func TestSendWithRetry_PreSendEditsPlaceholder(t *testing.T) {
m := newTestManager() m := newTestManager()
var sendCalled bool var sendCalled bool
@ -860,3 +907,286 @@ func TestBuildMediaScope_WithMessageID(t *testing.T) {
t.Fatalf("expected %s, got %s", expected, scope) t.Fatalf("expected %s, got %s", expected, scope)
} }
} }
func TestManager_PlaceholderConsumedByResponse(t *testing.T) {
mgr := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
placeholders: sync.Map{},
}
mockCh := &mockChannel{
sendFn: func(ctx context.Context, msg bus.OutboundMessage) error {
return nil
},
}
worker := newChannelWorker("mock", mockCh)
mgr.channels["mock"] = mockCh
mgr.workers["mock"] = worker
ctx := context.Background()
key := "mock:chat-1"
// Simulate a placeholder recorded by base.go HandleMessage
mgr.RecordPlaceholder("mock", "chat-1", "ph-123")
if _, ok := mgr.placeholders.Load(key); !ok {
t.Fatal("expected placeholder to be recorded")
}
// Transcription feedback arrives first — it should consume the placeholder
// and be delivered via EditMessage, not Send.
msgTranscript := bus.OutboundMessage{
Channel: "mock",
ChatID: "chat-1",
Content: "Transcript: hello",
}
mgr.sendWithRetry(ctx, "mock", worker, msgTranscript)
if mockCh.editedMessages != 1 {
t.Errorf("expected 1 edited message (placeholder consumed by transcript), got %d", mockCh.editedMessages)
}
if len(mockCh.sentMessages) != 0 {
t.Errorf("expected 0 normal messages (transcript used edit), got %d", len(mockCh.sentMessages))
}
// Placeholder should be gone now
if _, ok := mgr.placeholders.Load(key); ok {
t.Error("expected placeholder to be removed after being consumed")
}
// Final LLM response arrives — no placeholder left, so it goes through Send
msgFinal := bus.OutboundMessage{
Channel: "mock",
ChatID: "chat-1",
Content: "Final Answer",
}
mgr.sendWithRetry(ctx, "mock", worker, msgFinal)
if len(mockCh.sentMessages) != 1 {
t.Errorf("expected 1 normal message sent, got %d", len(mockCh.sentMessages))
}
}
func TestSendMessage_Synchronous(t *testing.T) {
m := newTestManager()
var received []bus.OutboundMessage
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
received = append(received, msg)
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "hello world",
ReplyToMessageID: "msg-456",
}
err := m.SendMessage(context.Background(), msg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
// SendMessage is synchronous — message should already be delivered
if len(received) != 1 {
t.Fatalf("expected 1 message sent, got %d", len(received))
}
if received[0].ReplyToMessageID != "msg-456" {
t.Fatalf("expected ReplyToMessageID msg-456, got %s", received[0].ReplyToMessageID)
}
if received[0].Content != "hello world" {
t.Fatalf("expected content 'hello world', got %s", received[0].Content)
}
}
func TestSendMessage_UnknownChannel(t *testing.T) {
m := newTestManager()
msg := bus.OutboundMessage{
Channel: "nonexistent",
ChatID: "123",
Content: "hello",
}
err := m.SendMessage(context.Background(), msg)
if err == nil {
t.Fatal("expected error for unknown channel")
}
}
func TestSendMessage_NoWorker(t *testing.T) {
m := newTestManager()
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
}
m.channels["test"] = ch
// No worker registered
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "hello",
}
err := m.SendMessage(context.Background(), msg)
if err == nil {
t.Fatal("expected error when no worker exists")
}
}
func TestSendMessage_WithRetry(t *testing.T) {
m := newTestManager()
var callCount int
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
callCount++
if callCount == 1 {
return fmt.Errorf("transient: %w", ErrTemporary)
}
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "retry me",
}
err := m.SendMessage(context.Background(), msg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if callCount != 2 {
t.Fatalf("expected 2 Send calls (1 failure + 1 success), got %d", callCount)
}
}
func TestSendMessage_WithSplitting(t *testing.T) {
m := newTestManager()
var received []string
ch := &mockChannelWithLength{
mockChannel: mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
received = append(received, msg.Content)
return nil
},
},
maxLen: 5,
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "hello world",
}
err := m.SendMessage(context.Background(), msg)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(received) < 2 {
t.Fatalf("expected message to be split into at least 2 chunks, got %d", len(received))
}
}
func TestSendMessage_PreservesOrdering(t *testing.T) {
m := newTestManager()
var order []string
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
order = append(order, msg.Content)
return nil
},
}
w := &channelWorker{
ch: ch,
limiter: rate.NewLimiter(rate.Inf, 1),
}
m.channels["test"] = ch
m.workers["test"] = w
// Send two messages sequentially — they must arrive in order
_ = m.SendMessage(context.Background(), bus.OutboundMessage{
Channel: "test", ChatID: "1", Content: "first",
})
_ = m.SendMessage(context.Background(), bus.OutboundMessage{
Channel: "test", ChatID: "1", Content: "second",
})
if len(order) != 2 {
t.Fatalf("expected 2 messages, got %d", len(order))
}
if order[0] != "first" || order[1] != "second" {
t.Fatalf("expected [first, second], got %v", order)
}
}
func TestManager_SendPlaceholder(t *testing.T) {
mgr := &Manager{
channels: make(map[string]Channel),
workers: make(map[string]*channelWorker),
placeholders: sync.Map{},
}
mockCh := &mockChannel{
sendFn: func(ctx context.Context, msg bus.OutboundMessage) error {
return nil
},
}
mgr.channels["mock"] = mockCh
ctx := context.Background()
// SendPlaceholder should send a placeholder and record it
ok := mgr.SendPlaceholder(ctx, "mock", "chat-1")
if !ok {
t.Fatal("expected SendPlaceholder to succeed")
}
if mockCh.placeholdersSent != 1 {
t.Errorf("expected 1 placeholder sent, got %d", mockCh.placeholdersSent)
}
key := "mock:chat-1"
if _, loaded := mgr.placeholders.Load(key); !loaded {
t.Error("expected placeholder to be recorded in manager")
}
// SendPlaceholder on unknown channel should return false
ok = mgr.SendPlaceholder(ctx, "unknown", "chat-1")
if ok {
t.Error("expected SendPlaceholder to fail for unknown channel")
}
}

View file

@ -0,0 +1,13 @@
package matrix
import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
)
func init() {
channels.RegisterFactory("matrix", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
return NewMatrixChannel(cfg.Channels.Matrix, b)
})
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,387 @@
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) {
re := localpartMentionRegexp("picoclaw")
cases := []struct {
text string
want bool
}{
{text: "@picoclaw hello", want: true},
{text: "hi @picoclaw:matrix.org", want: true},
{
text: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
want: false, // historical false-positive case in PR #356
},
{text: "mail test@example.com", want: false},
}
for _, tc := range cases {
if got := re.MatchString(tc.text); got != tc.want {
t.Fatalf("text=%q match=%v want=%v", tc.text, got, tc.want)
}
}
}
func TestStripUserMention(t *testing.T) {
userID := id.UserID("@picoclaw:matrix.org")
cases := []struct {
in string
want string
}{
{in: "@picoclaw:matrix.org hello", want: "hello"},
{in: "@picoclaw, hello", want: "hello"},
{in: "no mention here", want: "no mention here"},
}
for _, tc := range cases {
if got := stripUserMention(tc.in, userID); got != tc.want {
t.Fatalf("stripUserMention(%q)=%q want=%q", tc.in, got, tc.want)
}
}
}
func TestIsBotMentioned(t *testing.T) {
ch := &MatrixChannel{
client: &mautrix.Client{
UserID: id.UserID("@picoclaw:matrix.org"),
},
}
cases := []struct {
name string
msg event.MessageEventContent
want bool
}{
{
name: "mentions field",
msg: event.MessageEventContent{
Body: "hello",
Mentions: &event.Mentions{
UserIDs: []id.UserID{id.UserID("@picoclaw:matrix.org")},
},
},
want: true,
},
{
name: "full user id in body",
msg: event.MessageEventContent{
Body: "@picoclaw:matrix.org hello",
},
want: true,
},
{
name: "localpart with at sign",
msg: event.MessageEventContent{
Body: "@picoclaw hello",
},
want: true,
},
{
name: "localpart without at sign should not match",
msg: event.MessageEventContent{
Body: "\u6b22\u8fce\u4e00\u4e0bpicoclaw\u5c0f\u9f99\u867e",
},
want: false,
},
{
name: "formatted mention href matrix.to plain",
msg: event.MessageEventContent{
Body: "hello bot",
FormattedBody: `<a href="https://matrix.to/#/@picoclaw:matrix.org">PicoClaw</a> hello`,
},
want: true,
},
{
name: "formatted mention href matrix.to encoded",
msg: event.MessageEventContent{
Body: "hello bot",
FormattedBody: `<a href="https://matrix.to/#/%40picoclaw%3Amatrix.org">PicoClaw</a> hello`,
},
want: true,
},
}
for _, tc := range cases {
if got := ch.isBotMentioned(&tc.msg); got != tc.want {
t.Fatalf("%s: got=%v want=%v", tc.name, got, tc.want)
}
}
}
func TestRoomKindCache_ExpiresEntries(t *testing.T) {
cache := newRoomKindCache(4, 5*time.Second)
now := time.Unix(100, 0)
cache.set("!room:matrix.org", true, now)
if got, ok := cache.get("!room:matrix.org", now.Add(2*time.Second)); !ok || !got {
t.Fatalf("expected cached group room before ttl, got ok=%v group=%v", ok, got)
}
if _, ok := cache.get("!room:matrix.org", now.Add(6*time.Second)); ok {
t.Fatal("expected cache miss after ttl expiry")
}
}
func TestRoomKindCache_EvictsOldestWhenFull(t *testing.T) {
cache := newRoomKindCache(2, time.Minute)
now := time.Unix(200, 0)
cache.set("!room1:matrix.org", false, now)
cache.set("!room2:matrix.org", false, now.Add(1*time.Second))
cache.set("!room3:matrix.org", true, now.Add(2*time.Second))
if _, ok := cache.get("!room1:matrix.org", now.Add(2*time.Second)); ok {
t.Fatal("expected oldest cache entry to be evicted")
}
if got, ok := cache.get("!room2:matrix.org", now.Add(2*time.Second)); !ok || got {
t.Fatalf("expected room2 to remain and be direct, got ok=%v group=%v", ok, got)
}
if got, ok := cache.get("!room3:matrix.org", now.Add(2*time.Second)); !ok || !got {
t.Fatalf("expected room3 to remain and be group, got ok=%v group=%v", ok, got)
}
}
func TestMatrixMediaTempDir(t *testing.T) {
dir, err := matrixMediaTempDir()
if err != nil {
t.Fatalf("matrixMediaTempDir failed: %v", err)
}
if filepath.Base(dir) != matrixMediaTempDirName {
t.Fatalf("unexpected media dir base: %q", filepath.Base(dir))
}
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("media dir not created: %v", err)
}
if !info.IsDir() {
t.Fatalf("expected directory, got mode=%v", info.Mode())
}
}
func TestMatrixMediaExt(t *testing.T) {
if got := matrixMediaExt("photo.png", "", "image"); got != ".png" {
t.Fatalf("filename extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "image/webp", "image"); got != ".webp" {
t.Fatalf("content-type extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "", "image"); got != ".jpg" {
t.Fatalf("default image extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "", "audio"); got != ".ogg" {
t.Fatalf("default audio extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "", "video"); got != ".mp4" {
t.Fatalf("default video extension mismatch: got=%q", got)
}
if got := matrixMediaExt("", "", "file"); got != ".bin" {
t.Fatalf("default file extension mismatch: got=%q", got)
}
}
func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) {
const wantBody = "matrix-media-payload"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") {
t.Fatalf("unexpected download path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte(wantBody))
}))
defer server.Close()
client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
ch := &MatrixChannel{client: client}
msg := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: "image.png",
URL: id.ContentURIString("mxc://matrix.test/abc123"),
Info: &event.FileInfo{MimeType: "image/png"},
}
path, err := ch.downloadMedia(context.Background(), msg, "image")
if err != nil {
t.Fatalf("downloadMedia: %v", err)
}
defer os.Remove(path)
if ext := filepath.Ext(path); ext != ".png" {
t.Fatalf("temp file extension=%q want=.png", ext)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != wantBody {
t.Fatalf("file contents=%q want=%q", string(got), wantBody)
}
}
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
ch := &MatrixChannel{}
msg := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: "test.png",
}
content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
if !ok {
t.Fatal("expected ok for image fallback")
}
if content != "[image: test.png]" {
t.Fatalf("unexpected content: %q", content)
}
if len(mediaRefs) != 0 {
t.Fatalf("expected no media refs, got %d", len(mediaRefs))
}
}
func TestExtractInboundContent_AudioNoURLFallback(t *testing.T) {
ch := &MatrixChannel{}
msg := &event.MessageEventContent{
MsgType: event.MsgAudio,
FileName: "voice.ogg",
Body: "please transcribe",
}
content, mediaRefs, ok := ch.extractInboundContent(context.Background(), msg, "matrix:room:event")
if !ok {
t.Fatal("expected ok for audio fallback")
}
if content != "please transcribe\n[audio: voice.ogg]" {
t.Fatalf("unexpected content: %q", content)
}
if len(mediaRefs) != 0 {
t.Fatalf("expected no media refs, got %d", len(mediaRefs))
}
}
func TestMatrixOutboundMsgType(t *testing.T) {
cases := []struct {
name string
partType string
filename string
contentType string
want event.MessageType
}{
{name: "explicit image", partType: "image", want: event.MsgImage},
{name: "explicit audio", partType: "audio", want: event.MsgAudio},
{name: "mime fallback video", contentType: "video/mp4", want: event.MsgVideo},
{name: "extension fallback audio", filename: "voice.ogg", want: event.MsgAudio},
{name: "unknown defaults file", filename: "report.txt", want: event.MsgFile},
}
for _, tc := range cases {
if got := matrixOutboundMsgType(tc.partType, tc.filename, tc.contentType); got != tc.want {
t.Fatalf("%s: got=%q want=%q", tc.name, got, tc.want)
}
}
}
func TestMatrixOutboundContent(t *testing.T) {
content := matrixOutboundContent(
"please review",
"voice.ogg",
event.MsgAudio,
"audio/ogg",
1234,
id.ContentURIString("mxc://matrix.org/abc"),
)
if content.Body != "please review" {
t.Fatalf("unexpected body: %q", content.Body)
}
if content.FileName != "voice.ogg" {
t.Fatalf("unexpected filename: %q", content.FileName)
}
if content.Info == nil || content.Info.MimeType != "audio/ogg" {
t.Fatalf("unexpected content type: %+v", content.Info)
}
if content.Info == nil || content.Info.Size != 1234 {
t.Fatalf("unexpected size: %+v", content.Info)
}
noCaption := matrixOutboundContent(
"",
"image.png",
event.MsgImage,
"image/png",
0,
id.ContentURIString("mxc://matrix.org/def"),
)
if noCaption.Body != "image.png" {
t.Fatalf("unexpected fallback body: %q", noCaption.Body)
}
}
func TestMarkdownToHTML(t *testing.T) {
tests := []struct {
name string
input string
contains string
}{
{"bold", "**hello**", "<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)
}
}

View file

@ -3,7 +3,10 @@ package qq
import ( import (
"context" "context"
"fmt" "fmt"
"regexp"
"strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"github.com/tencent-connect/botgo" "github.com/tencent-connect/botgo"
@ -20,6 +23,14 @@ import (
"github.com/sipeed/picoclaw/pkg/logger" "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 { type QQChannel struct {
*channels.BaseChannel *channels.BaseChannel
config config.QQConfig config config.QQConfig
@ -28,20 +39,37 @@ type QQChannel struct {
ctx context.Context ctx context.Context
cancel context.CancelFunc cancel context.CancelFunc
sessionManager botgo.SessionManager 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) { func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(cfg.MaxMessageLength),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID), channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &QQChannel{ return &QQChannel{
BaseChannel: base, BaseChannel: base,
config: cfg, config: cfg,
processedIDs: make(map[string]bool), dedup: make(map[string]time.Time),
done: make(chan struct{}),
}, nil }, nil
} }
@ -50,8 +78,13 @@ func (c *QQChannel) Start(ctx context.Context) error {
return fmt.Errorf("QQ app_id and app_secret not configured") return fmt.Errorf("QQ app_id and app_secret not configured")
} }
botgo.SetLogger(logger.NewLogger("botgo"))
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)") 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 // create token source
credentials := &token.QQBotCredentials{ credentials := &token.QQBotCredentials{
AppID: c.config.AppID, 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) c.SetRunning(true)
logger.InfoC("qq", "QQ bot started successfully") 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") logger.InfoC("qq", "Stopping QQ bot")
c.SetRunning(false) c.SetRunning(false)
// Signal the dedup janitor to stop (idempotent).
c.stopOnce.Do(func() { close(c.done) })
if c.cancel != nil { if c.cancel != nil {
c.cancel() c.cancel()
} }
@ -116,21 +161,82 @@ func (c *QQChannel) Stop(ctx context.Context) error {
return nil 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 { func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() { if !c.IsRunning() {
return channels.ErrNotRunning return channels.ErrNotRunning
} }
// construct message chatKind := c.getChatKind(msg.ChatID)
// Build message with content.
msgToCreate := &dto.MessageToCreate{ msgToCreate := &dto.MessageToCreate{
Content: msg.Content, 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 { if err != nil {
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{ logger.ErrorCF("qq", "Failed to send message", map[string]any{
"error": err.Error(), "chat_id": msg.ChatID,
"chat_kind": chatKind,
"error": err.Error(),
}) })
return fmt.Errorf("qq send: %w", channels.ErrTemporary) 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 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 { func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error { return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
// deduplication check // deduplication check
@ -167,8 +416,16 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
"length": len(content), "length": len(content),
}) })
// 转发到消息总线 // Store chat routing context.
metadata := map[string]string{} c.chatType.Store(senderID, "direct")
c.lastMsgID.Store(senderID, data.ID)
// Reset msg_seq counter for new inbound message.
c.msgSeqCounters.Store(senderID, new(atomic.Uint64))
metadata := map[string]string{
"account_id": senderID,
}
sender := bus.SenderInfo{ sender := bus.SenderInfo{
Platform: "qq", Platform: "qq",
@ -195,7 +452,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
} }
} }
// handleGroupATMessage handles QQ group @ messages // handleGroupATMessage handles QQ group @ messages.
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler { func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error { return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
// deduplication check // deduplication check
@ -232,9 +489,16 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
"length": len(content), "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{ metadata := map[string]string{
"group_id": data.GroupID, "account_id": senderID,
"group_id": data.GroupID,
} }
sender := bus.SenderInfo{ sender := bus.SenderInfo{
@ -262,29 +526,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 { func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock() c.muDedup.Lock()
defer c.mu.Unlock() defer c.muDedup.Unlock()
if c.processedIDs[messageID] { if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL {
return true return true
} }
c.processedIDs[messageID] = true // Enforce hard cap: evict oldest entries when at capacity.
if len(c.dedup) >= dedupMaxSize {
// 简单清理:限制 map 大小 var oldestID string
if len(c.processedIDs) > 10000 { var oldestTS time.Time
// 清空一半 for id, ts := range c.dedup {
count := 0 if oldestID == "" || ts.Before(oldestTS) {
for id := range c.processedIDs { oldestID = id
if count >= 5000 { oldestTS = ts
break
} }
delete(c.processedIDs, id) }
count++ if oldestID != "" {
delete(c.dedup, oldestID)
} }
} }
c.dedup[messageID] = time.Now()
return false 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
})
}

View file

@ -0,0 +1,44 @@
package qq
import (
"context"
"testing"
"time"
"github.com/tencent-connect/botgo/dto"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
)
func TestHandleC2CMessage_IncludesAccountIDMetadata(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &QQChannel{
BaseChannel: channels.NewBaseChannel("qq", nil, messageBus, nil),
dedup: make(map[string]time.Time),
done: make(chan struct{}),
ctx: context.Background(),
}
err := ch.handleC2CMessage()(nil, &dto.WSC2CMessageData{
ID: "msg-1",
Content: "hello",
Author: &dto.User{
ID: "7750283E123456",
},
})
if err != nil {
t.Fatalf("handleC2CMessage() error = %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected inbound message")
}
if inbound.Metadata["account_id"] != "7750283E123456" {
t.Fatalf("account_id metadata = %q, want %q", inbound.Metadata["account_id"], "7750283E123456")
}
}

View file

@ -122,7 +122,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
slack.MsgOptionText(msg.Content, false), 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)) opts = append(opts, slack.MsgOptionTS(threadTS))
} }

View file

@ -77,6 +77,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" { if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
opts = append(opts, telego.WithAPIServer(baseURL)) opts = append(opts, telego.WithAPIServer(baseURL))
} }
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
bot, err := telego.NewBot(telegramCfg.Token, opts...) bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil { if err != nil {
@ -168,7 +169,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return channels.ErrNotRunning return channels.ErrNotRunning
} }
chatID, err := parseChatID(msg.ChatID) chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil { if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) 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), // The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to // 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. // check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
replyToID := msg.ReplyToMessageID
queue := []string{msg.Content} queue := []string{msg.Content}
for len(queue) > 0 { for len(queue) > 0 {
chunk := queue[0] chunk := queue[0]
@ -200,9 +202,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue 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 return err
} }
// Only the first chunk should be a reply; subsequent chunks are normal messages.
replyToID = ""
} }
return nil return nil
@ -210,9 +214,20 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// sendHTMLChunk sends a single HTML message, falling back to the original // 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. // 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 := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML tgMsg.ParseMode = telego.ModeHTML
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 { if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{ logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
@ -232,13 +247,16 @@ func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlC
// (Telegram's typing indicator expires after ~5s) in a background goroutine. // (Telegram's typing indicator expires after ~5s) in a background goroutine.
// The returned stop function is idempotent and cancels the goroutine. // The returned stop function is idempotent and cancels the goroutine.
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
cid, err := parseChatID(chatID) cid, threadID, err := parseTelegramChatID(chatID)
if err != nil { if err != nil {
return func() {}, err return func() {}, err
} }
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
action.MessageThreadID = threadID
// Send the first typing action immediately // Send the first typing action immediately
_ = c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) _ = c.bot.SendChatAction(ctx, action)
typingCtx, cancel := context.WithCancel(ctx) typingCtx, cancel := context.WithCancel(ctx)
go func() { go func() {
@ -249,7 +267,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
case <-typingCtx.Done(): case <-typingCtx.Done():
return return
case <-ticker.C: case <-ticker.C:
_ = c.bot.SendChatAction(typingCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)) a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
a.MessageThreadID = threadID
_ = c.bot.SendChatAction(typingCtx, a)
} }
} }
}() }()
@ -259,7 +279,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
// EditMessage implements channels.MessageEditor. // EditMessage implements channels.MessageEditor.
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { 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 { if err != nil {
return err return err
} }
@ -288,12 +308,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
text = "Thinking... 💭" text = "Thinking... 💭"
} }
cid, err := parseChatID(chatID) cid, threadID, err := parseTelegramChatID(chatID)
if err != nil { if err != nil {
return "", err return "", err
} }
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(cid), text)) phMsg := tu.Message(tu.ID(cid), text)
phMsg.MessageThreadID = threadID
pMsg, err := c.bot.SendMessage(ctx, phMsg)
if err != nil { if err != nil {
return "", err return "", err
} }
@ -307,7 +329,7 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
return channels.ErrNotRunning return channels.ErrNotRunning
} }
chatID, err := parseChatID(msg.ChatID) chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil { if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed) return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
} }
@ -339,30 +361,34 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe
switch part.Type { switch part.Type {
case "image": case "image":
params := &telego.SendPhotoParams{ params := &telego.SendPhotoParams{
ChatID: tu.ID(chatID), ChatID: tu.ID(chatID),
Photo: telego.InputFile{File: file}, MessageThreadID: threadID,
Caption: part.Caption, Photo: telego.InputFile{File: file},
Caption: part.Caption,
} }
_, err = c.bot.SendPhoto(ctx, params) _, err = c.bot.SendPhoto(ctx, params)
case "audio": case "audio":
params := &telego.SendAudioParams{ params := &telego.SendAudioParams{
ChatID: tu.ID(chatID), ChatID: tu.ID(chatID),
Audio: telego.InputFile{File: file}, MessageThreadID: threadID,
Caption: part.Caption, Audio: telego.InputFile{File: file},
Caption: part.Caption,
} }
_, err = c.bot.SendAudio(ctx, params) _, err = c.bot.SendAudio(ctx, params)
case "video": case "video":
params := &telego.SendVideoParams{ params := &telego.SendVideoParams{
ChatID: tu.ID(chatID), ChatID: tu.ID(chatID),
Video: telego.InputFile{File: file}, MessageThreadID: threadID,
Caption: part.Caption, Video: telego.InputFile{File: file},
Caption: part.Caption,
} }
_, err = c.bot.SendVideo(ctx, params) _, err = c.bot.SendVideo(ctx, params)
default: // "file" or unknown types default: // "file" or unknown types
params := &telego.SendDocumentParams{ params := &telego.SendDocumentParams{
ChatID: tu.ID(chatID), ChatID: tu.ID(chatID),
Document: telego.InputFile{File: file}, MessageThreadID: threadID,
Caption: part.Caption, Document: telego.InputFile{File: file},
Caption: part.Caption,
} }
_, err = c.bot.SendDocument(ctx, params) _, err = c.bot.SendDocument(ctx, params)
} }
@ -506,19 +532,28 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = cleaned content = cleaned
} }
// For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session.
// Only forum groups (IsForum) are handled; regular group reply threads
// must share one session per group.
compositeChatID := fmt.Sprintf("%d", chatID)
threadID := message.MessageThreadID
if message.Chat.IsForum && threadID != 0 {
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
}
logger.DebugCF("telegram", "Received message", map[string]any{ logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": sender.CanonicalID, "sender_id": sender.CanonicalID,
"chat_id": fmt.Sprintf("%d", chatID), "chat_id": compositeChatID,
"thread_id": threadID,
"preview": utils.Truncate(content, 50), "preview": utils.Truncate(content, 50),
}) })
// Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
peerKind := "direct" peerKind := "direct"
peerID := fmt.Sprintf("%d", user.ID) peerID := fmt.Sprintf("%d", user.ID)
if message.Chat.Type != "private" { if message.Chat.Type != "private" {
peerKind = "group" peerKind = "group"
peerID = fmt.Sprintf("%d", chatID) peerID = compositeChatID
} }
peer := bus.Peer{Kind: peerKind, ID: peerID} peer := bus.Peer{Kind: peerKind, ID: peerID}
@ -531,11 +566,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), "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, c.HandleMessage(c.ctx,
peer, peer,
messageID, messageID,
platformID, platformID,
fmt.Sprintf("%d", chatID), compositeChatID,
content, content,
mediaPaths, mediaPaths,
metadata, metadata,
@ -583,10 +624,23 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
return c.downloadFileWithInfo(file, ext) return c.downloadFileWithInfo(file, ext)
} }
func parseChatID(chatIDStr string) (int64, error) { // parseTelegramChatID splits "chatID/threadID" into its components.
var id int64 // Returns threadID=0 when no "/" is present (non-forum messages).
_, err := fmt.Sscanf(chatIDStr, "%d", &id) func parseTelegramChatID(chatID string) (int64, int, error) {
return id, err idx := strings.Index(chatID, "/")
if idx == -1 {
cid, err := strconv.ParseInt(chatID, 10, 64)
return cid, 0, err
}
cid, err := strconv.ParseInt(chatID[:idx], 10, 64)
if err != nil {
return 0, 0, err
}
tid, err := strconv.Atoi(chatID[idx+1:])
if err != nil {
return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err)
}
return cid, tid, nil
} }
func markdownToTelegramHTML(text string) string { func markdownToTelegramHTML(text string) string {

View file

@ -6,6 +6,7 @@ import (
"errors" "errors"
"strings" "strings"
"testing" "testing"
"time"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
ta "github.com/mymmrac/telego/telegoapi" ta "github.com/mymmrac/telego/telegoapi"
@ -271,3 +272,191 @@ func TestSend_InvalidChatID(t *testing.T) {
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed") assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
assert.Empty(t, caller.calls) 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"])
}

View file

@ -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{ cfgEmpty := config.WeComAppConfig{
CorpID: "test_corp_id", CorpID: "test_corp_id",
CorpSecret: "test_secret", CorpSecret: "test_secret",
@ -218,8 +218,8 @@ func TestWeComAppVerifySignature(t *testing.T) {
} }
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus) chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true") t.Error("empty token should reject verification (fail-closed)")
} }
}) })
} }

View file

@ -189,8 +189,7 @@ func TestWeComBotVerifySignature(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) {
// Create a channel manually with empty token to test the behavior
cfgEmpty := config.WeComConfig{ cfgEmpty := config.WeComConfig{
Token: "", Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test", WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
@ -199,8 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty, config: cfgEmpty,
} }
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") { if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true") t.Error("empty token should reject verification (fail-closed)")
} }
}) })
} }

View file

@ -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 // This is a common function used by both WeCom Bot and WeCom App
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool { func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" { if token == "" {
return true // Skip verification if token is not set return false
} }
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
} }

View file

@ -12,5 +12,6 @@ func BuiltinDefinitions() []Definition {
listCommand(), listCommand(),
switchCommand(), switchCommand(),
checkCommand(), checkCommand(),
clearCommand(),
} }
} }

20
pkg/commands/cmd_clear.go Normal file
View file

@ -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!")
},
}
}

View file

@ -13,4 +13,5 @@ type Runtime struct {
GetEnabledChannels func() []string GetEnabledChannels func() []string
SwitchModel func(value string) (oldModel string, err error) SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error SwitchChannel func(value string) error
ClearHistory func() error
} }

View file

@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strings"
"sync/atomic" "sync/atomic"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
@ -16,6 +17,8 @@ var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers, // FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123. // 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 type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
@ -47,6 +50,30 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
return nil 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 { type Config struct {
Agents AgentsConfig `json:"agents"` Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"` Bindings []AgentBinding `json:"bindings,omitempty"`
@ -58,6 +85,17 @@ type Config struct {
Tools ToolsConfig `json:"tools"` Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"` Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"` 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 // MarshalJSON implements custom JSON marshaling for Config
@ -184,8 +222,8 @@ type AgentDefaults struct {
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"` ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
@ -225,6 +263,7 @@ type ChannelsConfig struct {
QQ QQConfig `json:"qq"` QQ QQConfig `json:"qq"`
DingTalk DingTalkConfig `json:"dingtalk"` DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"` Slack SlackConfig `json:"slack"`
Matrix MatrixConfig `json:"matrix"`
LINE LINEConfig `json:"line"` LINE LINEConfig `json:"line"`
OneBot OneBotConfig `json:"onebot"` OneBot OneBotConfig `json:"onebot"`
WeCom WeComConfig `json:"wecom"` WeCom WeComConfig `json:"wecom"`
@ -273,15 +312,16 @@ type TelegramConfig struct {
} }
type FeishuConfig struct { type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
RandomReactionEmoji FlexibleStringSlice `json:"random_reaction_emoji" env:"PICOCLAW_CHANNELS_FEISHU_RANDOM_REACTION_EMOJI"`
} }
type DiscordConfig struct { type DiscordConfig struct {
@ -310,6 +350,8 @@ type QQConfig struct {
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` 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"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
} }
@ -333,6 +375,20 @@ type SlackConfig struct {
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
} }
type MatrixConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
}
type LINEConfig struct { type LINEConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"` ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
@ -444,6 +500,10 @@ type DevicesConfig struct {
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"` 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 { type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"` Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"` OpenAI OpenAIProviderConfig `json:"openai"`
@ -466,6 +526,9 @@ type ProvidersConfig struct {
Qwen ProviderConfig `json:"qwen"` Qwen ProviderConfig `json:"qwen"`
Mistral ProviderConfig `json:"mistral"` Mistral ProviderConfig `json:"mistral"`
Avian ProviderConfig `json:"avian"` Avian ProviderConfig `json:"avian"`
Minimax ProviderConfig `json:"minimax"`
LongCat ProviderConfig `json:"longcat"`
ModelScope ProviderConfig `json:"modelscope"`
} }
// IsEmpty checks if all provider configs are empty (no API keys or API bases set) // IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@ -491,7 +554,10 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" && p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" && p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.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 == "" &&
p.ModelScope.APIKey == "" && p.ModelScope.APIBase == ""
} }
// MarshalJSON implements custom JSON marshaling for ProvidersConfig // MarshalJSON implements custom JSON marshaling for ProvidersConfig
@ -565,21 +631,31 @@ type GatewayConfig struct {
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` 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 { type ToolConfig struct {
Enabled bool `json:"enabled" env:"ENABLED"` Enabled bool `json:"enabled" env:"ENABLED"`
} }
type BraveConfig struct { type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"` 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 { type TavilyConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"` APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"` 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 { type DuckDuckGoConfig struct {
@ -588,9 +664,10 @@ type DuckDuckGoConfig struct {
} }
type PerplexityConfig struct { type PerplexityConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` 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 { type SearXNGConfig struct {
@ -631,6 +708,7 @@ type CronToolsConfig struct {
type ExecConfig struct { type ExecConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"` ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"` 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"` 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"` 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) TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
@ -639,6 +717,7 @@ type ExecConfig struct {
type SkillsToolsConfig struct { type SkillsToolsConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"`
Registries SkillsRegistriesConfig ` json:"registries"` Registries SkillsRegistriesConfig ` json:"registries"`
Github SkillsGithubConfig ` json:"github"`
MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"` MaxConcurrentSearches int ` json:"max_concurrent_searches" env:"PICOCLAW_TOOLS_SKILLS_MAX_CONCURRENT_SEARCHES"`
SearchCache SearchCacheConfig ` json:"search_cache"` SearchCache SearchCacheConfig ` json:"search_cache"`
} }
@ -649,6 +728,11 @@ type MediaCleanupConfig struct {
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"` Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
} }
type ReadFileToolConfig struct {
Enabled bool `json:"enabled"`
MaxReadFileSize int `json:"max_read_file_size"`
}
type ToolsConfig struct { type ToolsConfig struct {
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"` AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"` AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
@ -665,7 +749,7 @@ type ToolsConfig struct {
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"` InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"` ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"` 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_"` SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"` Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
@ -683,6 +767,11 @@ type SkillsRegistriesConfig struct {
ClawHub ClawHubRegistryConfig `json:"clawhub"` ClawHub ClawHubRegistryConfig `json:"clawhub"`
} }
type SkillsGithubConfig struct {
Token string `json:"token,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_AUTH_TOKEN"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_SKILLS_GITHUB_PROXY"`
}
type ClawHubRegistryConfig struct { type ClawHubRegistryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_ENABLED"`
BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"` BaseURL string `json:"base_url" env:"PICOCLAW_SKILLS_REGISTRIES_CLAWHUB_BASE_URL"`
@ -717,7 +806,8 @@ type MCPServerConfig struct {
// MCPConfig defines configuration for all MCP servers // MCPConfig defines configuration for all MCP servers
type MCPConfig struct { 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 is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"` Servers map[string]MCPServerConfig `json:"servers,omitempty"`
} }
@ -910,6 +1000,29 @@ func (c *Config) ValidateModelList() error {
return nil 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 { func (t *ToolsConfig) IsToolEnabled(name string) bool {
switch name { switch name {
case "web": case "web":

View file

@ -283,6 +283,9 @@ func TestDefaultConfig_Channels(t *testing.T) {
if cfg.Channels.Slack.Enabled { if cfg.Channels.Slack.Enabled {
t.Error("Slack should be disabled by default") t.Error("Slack should be disabled by default")
} }
if cfg.Channels.Matrix.Enabled {
t.Error("Matrix should be disabled by default")
}
} }
// TestDefaultConfig_WebTools verifies web tools config // TestDefaultConfig_WebTools verifies web tools config
@ -293,7 +296,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
if cfg.Tools.Web.Brave.MaxResults != 5 { if cfg.Tools.Web.Brave.MaxResults != 5 {
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults) 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") t.Error("Brave API key should be empty by default")
} }
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 { if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
@ -339,8 +342,8 @@ func TestSaveConfig_IncludesEmptyLegacyModelField(t *testing.T) {
t.Fatalf("ReadFile failed: %v", err) t.Fatalf("ReadFile failed: %v", err)
} }
if !strings.Contains(string(data), `"model": ""`) { if !strings.Contains(string(data), `"model_name": ""`) {
t.Fatalf("saved config should include empty legacy model field, got: %s", string(data)) t.Fatalf("saved config should include empty legacy model_name field, got: %s", string(data))
} }
} }
@ -381,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) { func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
configPath := filepath.Join(dir, "config.json") configPath := filepath.Join(dir, "config.json")
@ -397,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) { func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
dir := t.TempDir() dir := t.TempDir()
configPath := filepath.Join(dir, "config.json") configPath := filepath.Join(dir, "config.json")
@ -418,7 +444,7 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
configPath := filepath.Join(tmpDir, "config.json") configPath := filepath.Join(tmpDir, "config.json")
configJSON := `{ configJSON := `{
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}}, "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"}} "tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
}` }`
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil { if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
@ -479,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) 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: "123456789",
expected: []string{"123", "456", "789"},
},
{
name: "Mixed English and Chinese commas",
input: "123,456789",
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,,456789",
expected: []string{"123", "456", "789"},
},
{
name: "Complex mixed values",
input: "user1@example.comuser2@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)
}
})
}

View file

@ -80,10 +80,11 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
}, },
QQ: QQConfig{ QQ: QQConfig{
Enabled: false, Enabled: false,
AppID: "", AppID: "",
AppSecret: "", AppSecret: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
MaxMessageLength: 2000,
}, },
DingTalk: DingTalkConfig{ DingTalk: DingTalkConfig{
Enabled: false, Enabled: false,
@ -97,6 +98,22 @@ func DefaultConfig() *Config {
AppToken: "", AppToken: "",
AllowFrom: FlexibleStringSlice{}, AllowFrom: FlexibleStringSlice{},
}, },
Matrix: MatrixConfig{
Enabled: false,
Homeserver: "https://matrix.org",
UserID: "",
AccessToken: "",
DeviceID: "",
JoinOnInvite: true,
AllowFrom: FlexibleStringSlice{},
GroupTrigger: GroupTriggerConfig{
MentionOnly: true,
},
Placeholder: PlaceholderConfig{
Enabled: true,
Text: "Thinking... 💭",
},
},
LINE: LINEConfig{ LINE: LINEConfig{
Enabled: false, Enabled: false,
ChannelSecret: "", ChannelSecret: "",
@ -196,6 +213,13 @@ func DefaultConfig() *Config {
Brave: BraveConfig{ Brave: BraveConfig{
Enabled: false, Enabled: false,
APIKey: "", APIKey: "",
APIKeys: nil,
MaxResults: 5,
},
Tavily: TavilyConfig{
Enabled: false,
APIKey: "",
APIKeys: nil,
MaxResults: 5, MaxResults: 5,
}, },
DuckDuckGo: DuckDuckGoConfig{ DuckDuckGo: DuckDuckGoConfig{
@ -205,6 +229,7 @@ func DefaultConfig() *Config {
Perplexity: PerplexityConfig{ Perplexity: PerplexityConfig{
Enabled: false, Enabled: false,
APIKey: "", APIKey: "",
APIKeys: nil,
MaxResults: 5, MaxResults: 5,
}, },
SearXNG: SearXNGConfig{ SearXNG: SearXNGConfig{
@ -231,6 +256,7 @@ func DefaultConfig() *Config {
Enabled: true, Enabled: true,
}, },
EnableDenyPatterns: true, EnableDenyPatterns: true,
AllowRemote: true,
TimeoutSeconds: 60, TimeoutSeconds: 60,
}, },
Skills: SkillsToolsConfig{ Skills: SkillsToolsConfig{
@ -256,6 +282,13 @@ func DefaultConfig() *Config {
ToolConfig: ToolConfig{ ToolConfig: ToolConfig{
Enabled: false, Enabled: false,
}, },
Discovery: ToolDiscoveryConfig{
Enabled: false,
TTL: 5,
MaxSearchResults: 5,
UseBM25: true,
UseRegex: false,
},
Servers: map[string]MCPServerConfig{}, Servers: map[string]MCPServerConfig{},
}, },
AppendFile: ToolConfig{ AppendFile: ToolConfig{
@ -279,8 +312,9 @@ func DefaultConfig() *Config {
Message: ToolConfig{ Message: ToolConfig{
Enabled: true, Enabled: true,
}, },
ReadFile: ToolConfig{ ReadFile: ReadFileToolConfig{
Enabled: true, Enabled: true,
MaxReadFileSize: 64 * 1024, // 64KB
}, },
Spawn: ToolConfig{ Spawn: ToolConfig{
Enabled: true, Enabled: true,
@ -306,6 +340,15 @@ func DefaultConfig() *Config {
Enabled: false, Enabled: false,
MonitorUSB: true, MonitorUSB: true,
}, },
Voice: VoiceConfig{
EchoTranscription: false,
},
BuildInfo: BuildInfo{
Version: Version,
GitCommit: GitCommit,
BuildTime: BuildTime,
GoVersion: GoVersion,
},
} }
} }

View file

@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
} }
return ModelConfig{ return ModelConfig{
ModelName: "openai", ModelName: "openai",
Model: "openai/gpt-5.2", Model: "openai/gpt-5.4",
APIKey: p.OpenAI.APIKey, APIKey: p.OpenAI.APIKey,
APIBase: p.OpenAI.APIBase, APIBase: p.OpenAI.APIBase,
Proxy: p.OpenAI.Proxy, Proxy: p.OpenAI.Proxy,
@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
} }
return ModelConfig{ return ModelConfig{
ModelName: "github-copilot", ModelName: "github-copilot",
Model: "github-copilot/gpt-5.2", Model: "github-copilot/gpt-5.4",
APIBase: p.GitHubCopilot.APIBase, APIBase: p.GitHubCopilot.APIBase,
ConnectMode: p.GitHubCopilot.ConnectMode, ConnectMode: p.GitHubCopilot.ConnectMode,
}, true }, true
@ -407,6 +407,40 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true }, true
}, },
}, },
{
providerNames: []string{"longcat"},
protocol: "longcat",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: p.LongCat.APIKey,
APIBase: p.LongCat.APIBase,
Proxy: p.LongCat.Proxy,
RequestTimeout: p.LongCat.RequestTimeout,
}, true
},
},
{
providerNames: []string{"modelscope"},
protocol: "modelscope",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.ModelScope.APIKey == "" && p.ModelScope.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "modelscope",
Model: "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
APIKey: p.ModelScope.APIKey,
APIBase: p.ModelScope.APIBase,
Proxy: p.ModelScope.Proxy,
RequestTimeout: p.ModelScope.RequestTimeout,
}, true
},
},
} }
// Process each provider migration // Process each provider migration

View file

@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
if result[0].ModelName != "openai" { if result[0].ModelName != "openai" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai") t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
} }
if 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.2") t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4")
} }
if result[0].APIKey != "sk-test-key" { if result[0].APIKey != "sk-test-key" {
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key") t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
@ -162,14 +162,16 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
Qwen: ProviderConfig{APIKey: "key17"}, Qwen: ProviderConfig{APIKey: "key17"},
Mistral: ProviderConfig{APIKey: "key18"}, Mistral: ProviderConfig{APIKey: "key18"},
Avian: ProviderConfig{APIKey: "key19"}, Avian: ProviderConfig{APIKey: "key19"},
LongCat: ProviderConfig{APIKey: "key-longcat"},
ModelScope: ProviderConfig{APIKey: "key-modelscope"},
}, },
} }
result := ConvertProvidersToModelList(cfg) result := ConvertProvidersToModelList(cfg)
// All 21 providers should be converted // All 23 providers should be converted
if len(result) != 21 { if len(result) != 23 {
t.Errorf("len(result) = %d, want 21", len(result)) t.Errorf("len(result) = %d, want 23", len(result))
} }
} }
@ -383,8 +385,8 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
for _, mc := range result { for _, mc := range result {
switch mc.ModelName { switch mc.ModelName {
case "openai": case "openai":
if 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.2") t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4")
} }
case "deepseek": case "deepseek":
if mc.Model != "deepseek/deepseek-reasoner" { if mc.Model != "deepseek/deepseek-reasoner" {
@ -557,9 +559,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
// Tests for buildModelWithProtocol helper function // Tests for buildModelWithProtocol helper function
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) { func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
result := buildModelWithProtocol("openai", "gpt-5.2") result := buildModelWithProtocol("openai", "gpt-5.4")
if result != "openai/gpt-5.2" { if result != "openai/gpt-5.4" {
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2") t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
} }
} }

44
pkg/config/version.go Normal file
View file

@ -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
}

View file

@ -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)
}

View file

@ -59,6 +59,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
} }
} }
// Keep track of explicit username format
isAtUsername := strings.HasPrefix(allowed, "@")
// Strip leading "@" for username matching // Strip leading "@" for username matching
trimmed := strings.TrimPrefix(allowed, "@") trimmed := strings.TrimPrefix(allowed, "@")
@ -75,11 +78,9 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
return true return true
} }
// Match against Username // Match against Username only when explicitly requested via "@username"
if sender.Username != "" { if isAtUsername && sender.Username != "" && sender.Username == trimmed {
if sender.Username == trimmed || sender.Username == allowedUser { return true
return true
}
} }
// Match compound sender format against allowed parts // Match compound sender format against allowed parts

View file

@ -104,6 +104,16 @@ func TestMatchAllowed(t *testing.T) {
allowed: "@alice", allowed: "@alice",
want: true, want: true,
}, },
{
name: "plain entry does not match username",
sender: bus.SenderInfo{
Platform: "discord",
PlatformID: "999999",
Username: "123456",
},
allowed: "123456",
want: false,
},
{ {
name: "@username does not match", name: "@username does not match",
sender: telegramSender, sender: telegramSender,
@ -123,6 +133,16 @@ func TestMatchAllowed(t *testing.T) {
allowed: "999|alice", allowed: "999|alice",
want: true, want: true,
}, },
{
name: "compound matches by ID when username differs",
sender: bus.SenderInfo{
Platform: "discord",
PlatformID: "123456",
Username: "not123456",
},
allowed: "123456|alice",
want: true,
},
{ {
name: "compound does not match", name: "compound does not match",
sender: telegramSender, sender: telegramSender,

View file

@ -1,24 +1,25 @@
package logger package logger
import ( import (
"encoding/json"
"fmt" "fmt"
"log"
"os" "os"
"path/filepath"
"runtime" "runtime"
"strconv"
"strings" "strings"
"sync" "sync"
"time"
"github.com/rs/zerolog"
) )
type LogLevel int type LogLevel = zerolog.Level
const ( const (
DEBUG LogLevel = iota DEBUG = zerolog.DebugLevel
INFO INFO = zerolog.InfoLevel
WARN WARN = zerolog.WarnLevel
ERROR ERROR = zerolog.ErrorLevel
FATAL FATAL = zerolog.FatalLevel
) )
var ( var (
@ -31,34 +32,66 @@ var (
} }
currentLevel = INFO currentLevel = INFO
logger *Logger logger zerolog.Logger
fileLogger zerolog.Logger
logFile *os.File
once sync.Once once sync.Once
mu sync.RWMutex mu sync.RWMutex
) )
type Logger struct {
file *os.File
}
type LogEntry struct {
Level string `json:"level"`
Timestamp string `json:"timestamp"`
Component string `json:"component,omitempty"`
Message string `json:"message"`
Fields map[string]any `json:"fields,omitempty"`
Caller string `json:"caller,omitempty"`
}
func init() { func init() {
once.Do(func() { once.Do(func() {
logger = &Logger{} zerolog.SetGlobalLevel(zerolog.InfoLevel)
consoleWriter := zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: "15:04:05", // TODO: make it configurable???
// Custom formatter to handle multiline strings and JSON objects
FormatFieldValue: formatFieldValue,
}
logger = zerolog.New(consoleWriter).With().Timestamp().Logger()
fileLogger = zerolog.Logger{}
}) })
} }
func formatFieldValue(i any) string {
var s string
switch val := i.(type) {
case string:
s = val
case []byte:
s = string(val)
default:
return fmt.Sprintf("%v", i)
}
if unquoted, err := strconv.Unquote(s); err == nil {
s = unquoted
}
if strings.Contains(s, "\n") {
return fmt.Sprintf("\n%s", s)
}
if strings.Contains(s, " ") {
if (strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}")) ||
(strings.HasPrefix(s, "[") && strings.HasSuffix(s, "]")) {
return s
}
return fmt.Sprintf("%q", s)
}
return s
}
func SetLevel(level LogLevel) { func SetLevel(level LogLevel) {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
currentLevel = level currentLevel = level
zerolog.SetGlobalLevel(level)
} }
func GetLevel() LogLevel { func GetLevel() LogLevel {
@ -71,17 +104,22 @@ func EnableFileLogging(filePath string) error {
mu.Lock() mu.Lock()
defer mu.Unlock() 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 { if err != nil {
return fmt.Errorf("failed to open log file: %w", err) return fmt.Errorf("failed to open log file: %w", err)
} }
if logger.file != nil { // Close old file if exists
logger.file.Close() if logFile != nil {
logFile.Close()
} }
logger.file = file logFile = newFile
log.Println("File logging enabled:", filePath) fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger()
return nil return nil
} }
@ -89,10 +127,58 @@ func DisableFileLogging() {
mu.Lock() mu.Lock()
defer mu.Unlock() defer mu.Unlock()
if logger.file != nil { if logFile != nil {
logger.file.Close() logFile.Close()
logger.file = nil logFile = nil
log.Println("File logging disabled") }
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, "/logger_3rd_party.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()
} }
} }
@ -101,63 +187,55 @@ func logMessage(level LogLevel, component string, message string, fields map[str
return return
} }
entry := LogEntry{ callerFile, callerLine, callerFunc := getCallerInfo()
Level: logLevelNames[level],
Timestamp: time.Now().UTC().Format(time.RFC3339),
Component: component,
Message: message,
Fields: fields,
}
if pc, file, line, ok := runtime.Caller(2); ok { event := getEvent(logger, level)
fn := runtime.FuncForPC(pc)
if fn != nil {
entry.Caller = fmt.Sprintf("%s:%d (%s)", file, line, fn.Name())
}
}
if logger.file != nil { // Build combined field with component and caller
jsonData, err := json.Marshal(entry) if component != "" {
if err == nil { event.Str("caller", fmt.Sprintf("%-6s %s:%d (%s)", component, callerFile, callerLine, callerFunc))
logger.file.Write(append(jsonData, '\n'))
}
}
var fieldStr string
if len(fields) > 0 {
fieldStr = " " + formatFields(fields)
} else { } else {
fieldStr = "" event.Str("caller", fmt.Sprintf("<none> %s:%d (%s)", callerFile, callerLine, callerFunc))
} }
logLine := fmt.Sprintf("[%s] [%s]%s %s%s", appendFields(event, fields)
entry.Timestamp, event.Msg(message)
logLevelNames[level],
formatComponent(component),
message,
fieldStr,
)
log.Println(logLine) // Also log to file if enabled
if fileLogger.GetLevel() != zerolog.NoLevel {
fileEvent := getEvent(fileLogger, level)
if component != "" {
fileEvent.Str("component", component)
}
appendFields(event, fields)
fileEvent.Msg(message)
}
if level == FATAL { if level == FATAL {
os.Exit(1) os.Exit(1)
} }
} }
func formatComponent(component string) string { func appendFields(event *zerolog.Event, fields map[string]any) {
if component == "" {
return ""
}
return fmt.Sprintf(" %s:", component)
}
func formatFields(fields map[string]any) string {
parts := make([]string, 0, len(fields))
for k, v := range fields { for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v)) // Type switch to avoid double JSON serialization of strings
switch val := v.(type) {
case string:
event.Str(k, val)
case int:
event.Int(k, val)
case int64:
event.Int64(k, val)
case float64:
event.Float64(k, val)
case bool:
event.Bool(k, val)
default:
event.Interface(k, v) // Fallback for struct, slice and maps
}
} }
return fmt.Sprintf("{%s}", strings.Join(parts, ", "))
} }
func Debug(message string) { func Debug(message string) {
@ -168,6 +246,10 @@ func DebugC(component string, message string) {
logMessage(DEBUG, component, message, nil) logMessage(DEBUG, component, message, nil)
} }
func Debugf(message string, ss ...any) {
logMessage(DEBUG, "", fmt.Sprintf(message, ss...), nil)
}
func DebugF(message string, fields map[string]any) { func DebugF(message string, fields map[string]any) {
logMessage(DEBUG, "", message, fields) logMessage(DEBUG, "", message, fields)
} }
@ -188,6 +270,10 @@ func InfoF(message string, fields map[string]any) {
logMessage(INFO, "", message, fields) logMessage(INFO, "", message, fields)
} }
func Infof(message string, ss ...any) {
logMessage(INFO, "", fmt.Sprintf(message, ss...), nil)
}
func InfoCF(component string, message string, fields map[string]any) { func InfoCF(component string, message string, fields map[string]any) {
logMessage(INFO, component, message, fields) logMessage(INFO, component, message, fields)
} }
@ -216,6 +302,10 @@ func ErrorC(component string, message string) {
logMessage(ERROR, component, message, nil) logMessage(ERROR, component, message, nil)
} }
func Errorf(message string, ss ...any) {
logMessage(ERROR, "", fmt.Sprintf(message, ss...), nil)
}
func ErrorF(message string, fields map[string]any) { func ErrorF(message string, fields map[string]any) {
logMessage(ERROR, "", message, fields) logMessage(ERROR, "", message, fields)
} }
@ -232,6 +322,10 @@ func FatalC(component string, message string) {
logMessage(FATAL, component, message, nil) 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) { func FatalF(message string, fields map[string]any) {
logMessage(FATAL, "", message, fields) logMessage(FATAL, "", message, fields)
} }

View file

@ -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}
}

View file

@ -123,17 +123,132 @@ func TestLoggerHelperFunctions(t *testing.T) {
SetLevel(INFO) SetLevel(INFO)
Debug("This should not log") Debug("This should not log")
Debugf("this should not log")
Info("This should log") Info("This should log")
Warn("This should log") Warn("This should log")
Error("This should log") Error("This should log")
InfoC("test", "Component message") InfoC("test", "Component message")
InfoF("Fields message", map[string]any{"key": "value"}) InfoF("Fields message", map[string]any{"key": "value"})
Infof("test from %v", "Infof")
WarnC("test", "Warning with component") WarnC("test", "Warning with component")
ErrorF("Error with fields", map[string]any{"error": "test"}) ErrorF("Error with fields", map[string]any{"error": "test"})
Errorf("test from %v", "Errorf")
SetLevel(DEBUG) SetLevel(DEBUG)
DebugC("test", "Debug with component") DebugC("test", "Debug with component")
Debugf("test from %v", "Debugf")
WarnF("Warning with fields", map[string]any{"key": "value"}) WarnF("Warning with fields", map[string]any{"key": "value"})
} }
func TestFormatFieldValue(t *testing.T) {
tests := []struct {
name string
input any
expected string
}{
// Basic types test (default case of the switch)
{
name: "Integer Type",
input: 42,
expected: "42",
},
{
name: "Boolean Type",
input: true,
expected: "true",
},
{
name: "Unsupported Struct Type",
input: struct{ A int }{A: 1},
expected: "{1}",
},
// Simple strings and byte slices test
{
name: "Simple string without spaces",
input: "simple_value",
expected: "simple_value",
},
{
name: "Simple byte slice",
input: []byte("byte_value"),
expected: "byte_value",
},
// Unquoting test (strconv.Unquote)
{
name: "Quoted string",
input: `"quoted_value"`,
expected: "quoted_value",
},
// Strings with newline (\n) test
{
name: "String with newline",
input: "line1\nline2",
expected: "\nline1\nline2",
},
{
name: "Quoted string with newline (Unquote -> newline)",
input: `"line1\nline2"`, // Escaped \n that Unquote will resolve
expected: "\nline1\nline2",
},
// Strings with spaces test (which should be quoted)
{
name: "String with spaces",
input: "hello world",
expected: `"hello world"`,
},
{
name: "Quoted string with spaces (Unquote -> has spaces -> Re-quote)",
input: `"hello world"`,
expected: `"hello world"`,
},
// JSON formats test (strings with spaces that start/end with brackets)
{
name: "Valid JSON object",
input: `{"key": "value"}`,
expected: `{"key": "value"}`,
},
{
name: "Valid JSON array",
input: `[1, 2, "three"]`,
expected: `[1, 2, "three"]`,
},
{
name: "Fake JSON (starts with { but doesn't end with })",
input: `{"key": "value"`, // Missing closing bracket, has spaces
expected: `"{\"key\": \"value\""`,
},
{
name: "Empty JSON (object)",
input: `{ }`,
expected: `{ }`,
},
// 7. Edge Cases
{
name: "Empty string",
input: "",
expected: "",
},
{
name: "Whitespace only string",
input: " ",
expected: `" "`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual := formatFieldValue(tt.input)
if actual != tt.expected {
t.Errorf("formatFieldValue() = %q, expected %q", actual, tt.expected)
}
})
}
}

View file

@ -86,14 +86,14 @@ func (s *JSONLStore) metaPath(key string) string {
// sanitizeKey converts a session key to a safe filename component. // sanitizeKey converts a session key to a safe filename component.
// Mirrors pkg/session.sanitizeFilename so that migration paths match. // Mirrors pkg/session.sanitizeFilename so that migration paths match.
// // Replaces ':' with '_' (session key separator) and '/' and '\' with '_'
// Note: this is a lossy mapping — "telegram:123" and "telegram_123" // so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts")
// both produce the same filename. This is an intentional tradeoff: // do not create subdirectories or break on Windows.
// 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.
func sanitizeKey(key string) string { 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. // readMeta loads the metadata file for a session.

View file

@ -48,6 +48,12 @@ func MigrateFromJSON(
if !strings.HasSuffix(name, ".json") { if !strings.HasSuffix(name, ".json") {
continue 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. // Skip already-migrated files.
if strings.HasSuffix(name, ".migrated") { if strings.HasSuffix(name, ".migrated") {
continue continue

View file

@ -382,3 +382,55 @@ func TestMigrateFromJSON_NonexistentDir(t *testing.T) {
t.Errorf("expected 0, got %d", count) 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)
}
}

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