mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-08-04 01:19:36 +00:00
Merge branch 'main' into mcp-tools-support
# Conflicts: # go.mod # go.sum # pkg/agent/loop.go # pkg/config/config.go
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@ build/
|
|||||||
*.out
|
*.out
|
||||||
/picoclaw
|
/picoclaw
|
||||||
/picoclaw-test
|
/picoclaw-test
|
||||||
cmd/picoclaw/workspace
|
cmd/**/workspace
|
||||||
|
|
||||||
# Picoclaw specific
|
# Picoclaw specific
|
||||||
|
|
||||||
|
|||||||
+3
-10
@@ -28,9 +28,7 @@ linters:
|
|||||||
- wsl_v5
|
- wsl_v5
|
||||||
|
|
||||||
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
# TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step)
|
||||||
- bodyclose
|
|
||||||
- contextcheck
|
- contextcheck
|
||||||
- dogsled
|
|
||||||
- embeddedstructfieldcheck
|
- embeddedstructfieldcheck
|
||||||
- errcheck
|
- errcheck
|
||||||
- errchkjson
|
- errchkjson
|
||||||
@@ -45,32 +43,24 @@ linters:
|
|||||||
- gocritic
|
- gocritic
|
||||||
- gocyclo
|
- gocyclo
|
||||||
- godox
|
- godox
|
||||||
- goprintffuncname
|
|
||||||
- gosec
|
- gosec
|
||||||
- ineffassign
|
- ineffassign
|
||||||
- lll
|
- lll
|
||||||
- maintidx
|
- maintidx
|
||||||
- misspell
|
|
||||||
- mnd
|
- mnd
|
||||||
- modernize
|
- modernize
|
||||||
- nakedret
|
|
||||||
- nestif
|
- nestif
|
||||||
- nilnil
|
- nilnil
|
||||||
- paralleltest
|
- paralleltest
|
||||||
- perfsprint
|
- perfsprint
|
||||||
- prealloc
|
|
||||||
- predeclared
|
|
||||||
- revive
|
- revive
|
||||||
- staticcheck
|
- staticcheck
|
||||||
- tagalign
|
- tagalign
|
||||||
- testifylint
|
- testifylint
|
||||||
- thelper
|
- thelper
|
||||||
- unparam
|
- unparam
|
||||||
- unused
|
|
||||||
- usestdlibvars
|
- usestdlibvars
|
||||||
- usetesting
|
- usetesting
|
||||||
- wastedassign
|
|
||||||
- whitespace
|
|
||||||
settings:
|
settings:
|
||||||
errcheck:
|
errcheck:
|
||||||
check-type-assertions: true
|
check-type-assertions: true
|
||||||
@@ -152,6 +142,9 @@ linters:
|
|||||||
- gocognit
|
- gocognit
|
||||||
- gocyclo
|
- gocyclo
|
||||||
path: _test\.go$
|
path: _test\.go$
|
||||||
|
- linters:
|
||||||
|
- nolintlint
|
||||||
|
path: 'pkg/tools/(i2c\.go|spi\.go)$'
|
||||||
|
|
||||||
issues:
|
issues:
|
||||||
max-issues-per-linter: 0
|
max-issues-per-linter: 0
|
||||||
|
|||||||
+30
-8
@@ -5,7 +5,7 @@ version: 2
|
|||||||
before:
|
before:
|
||||||
hooks:
|
hooks:
|
||||||
- go mod tidy
|
- go mod tidy
|
||||||
- go generate ./cmd/picoclaw
|
- go generate ./cmd/picoclaw/...
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
@@ -15,10 +15,10 @@ builds:
|
|||||||
- stdjson
|
- stdjson
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w
|
- -s -w
|
||||||
- -X main.version={{ .Version }}
|
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
|
||||||
- -X main.gitCommit={{ .ShortCommit }}
|
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
|
||||||
- -X main.buildTime={{ .Date }}
|
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
|
||||||
- -X main.goVersion={{ .Env.GOVERSION }}
|
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- windows
|
- windows
|
||||||
@@ -28,9 +28,10 @@ builds:
|
|||||||
- amd64
|
- amd64
|
||||||
- arm64
|
- arm64
|
||||||
- riscv64
|
- riscv64
|
||||||
- s390x
|
- loong64
|
||||||
- mips64
|
|
||||||
- arm
|
- arm
|
||||||
|
goarm:
|
||||||
|
- "7"
|
||||||
main: ./cmd/picoclaw
|
main: ./cmd/picoclaw
|
||||||
ignore:
|
ignore:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
@@ -38,7 +39,9 @@ builds:
|
|||||||
|
|
||||||
dockers_v2:
|
dockers_v2:
|
||||||
- id: picoclaw
|
- id: picoclaw
|
||||||
dockerfile: Dockerfile.goreleaser
|
dockerfile: docker/Dockerfile.goreleaser
|
||||||
|
extra_files:
|
||||||
|
- docker/entrypoint.sh
|
||||||
ids:
|
ids:
|
||||||
- picoclaw
|
- picoclaw
|
||||||
images:
|
images:
|
||||||
@@ -67,6 +70,25 @@ archives:
|
|||||||
- goos: windows
|
- goos: windows
|
||||||
formats: [zip]
|
formats: [zip]
|
||||||
|
|
||||||
|
nfpms:
|
||||||
|
- id: picoclaw
|
||||||
|
package_name: picoclaw
|
||||||
|
file_name_template: >-
|
||||||
|
{{ .PackageName }}_
|
||||||
|
{{- if eq .Arch "amd64" }}x86_64
|
||||||
|
{{- else if eq .Arch "arm64" }}aarch64
|
||||||
|
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
|
||||||
|
{{- else }}{{ .Arch }}{{ end }}
|
||||||
|
vendor: picoclaw
|
||||||
|
homepage: https://github.com/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw
|
||||||
|
maintainer: picoclaw contributors
|
||||||
|
description: picoclaw - a tool for managing and running tasks
|
||||||
|
license: MIT
|
||||||
|
formats:
|
||||||
|
- rpm
|
||||||
|
- deb
|
||||||
|
bindir: /usr/bin
|
||||||
|
|
||||||
changelog:
|
changelog:
|
||||||
sort: asc
|
sort: asc
|
||||||
filters:
|
filters:
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ 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}')
|
||||||
LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
|
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
|
||||||
|
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
|
||||||
|
|
||||||
# Go variables
|
# Go variables
|
||||||
GO?=go
|
GO?=CGO_ENABLED=0 go
|
||||||
GOFLAGS?=-v -tags stdjson
|
GOFLAGS?=-v -tags stdjson
|
||||||
|
|
||||||
# Golangci-lint
|
# Golangci-lint
|
||||||
@@ -24,6 +25,7 @@ GOLANGCI_LINT?=golangci-lint
|
|||||||
INSTALL_PREFIX?=$(HOME)/.local
|
INSTALL_PREFIX?=$(HOME)/.local
|
||||||
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
INSTALL_BIN_DIR=$(INSTALL_PREFIX)/bin
|
||||||
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
INSTALL_MAN_DIR=$(INSTALL_PREFIX)/share/man/man1
|
||||||
|
INSTALL_TMP_SUFFIX=.new
|
||||||
|
|
||||||
# Workspace and Skills
|
# Workspace and Skills
|
||||||
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
PICOCLAW_HOME?=$(HOME)/.picoclaw
|
||||||
@@ -42,6 +44,8 @@ ifeq ($(UNAME_S),Linux)
|
|||||||
ARCH=amd64
|
ARCH=amd64
|
||||||
else ifeq ($(UNAME_M),aarch64)
|
else ifeq ($(UNAME_M),aarch64)
|
||||||
ARCH=arm64
|
ARCH=arm64
|
||||||
|
else ifeq ($(UNAME_M),armv81)
|
||||||
|
ARCH=arm64
|
||||||
else ifeq ($(UNAME_M),loongarch64)
|
else ifeq ($(UNAME_M),loongarch64)
|
||||||
ARCH=loong64
|
ARCH=loong64
|
||||||
else ifeq ($(UNAME_M),riscv64)
|
else ifeq ($(UNAME_M),riscv64)
|
||||||
@@ -83,14 +87,50 @@ 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-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
|
||||||
|
build-whatsapp-native: generate
|
||||||
|
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
|
||||||
|
@echo "Building for multiple platforms..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
||||||
|
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
|
||||||
|
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
|
||||||
|
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
|
||||||
|
@echo "Build complete"
|
||||||
|
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
|
||||||
|
|
||||||
|
## build-linux-arm: Build for Linux ARMv7 (e.g. Raspberry Pi Zero 2 W 32-bit)
|
||||||
|
build-linux-arm: generate
|
||||||
|
@echo "Building for linux/arm (GOARM=7)..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
|
||||||
|
|
||||||
|
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
|
||||||
|
build-linux-arm64: generate
|
||||||
|
@echo "Building for linux/arm64..."
|
||||||
|
@mkdir -p $(BUILD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
|
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
|
||||||
|
|
||||||
|
## build-pi-zero: Build for Raspberry Pi Zero 2 W (32-bit and 64-bit)
|
||||||
|
build-pi-zero: build-linux-arm build-linux-arm64
|
||||||
|
@echo "Pi Zero 2 W builds: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm (32-bit), $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 (64-bit)"
|
||||||
|
|
||||||
## build-all: Build picoclaw for all platforms
|
## build-all: Build picoclaw for all platforms
|
||||||
build-all: generate
|
build-all: generate
|
||||||
@echo "Building for multiple platforms..."
|
@echo "Building for multiple platforms..."
|
||||||
@mkdir -p $(BUILD_DIR)
|
@mkdir -p $(BUILD_DIR)
|
||||||
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
|
||||||
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
|
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(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)
|
||||||
@echo "All builds complete"
|
@echo "All builds complete"
|
||||||
@@ -99,8 +139,10 @@ build-all: generate
|
|||||||
install: build
|
install: build
|
||||||
@echo "Installing $(BINARY_NAME)..."
|
@echo "Installing $(BINARY_NAME)..."
|
||||||
@mkdir -p $(INSTALL_BIN_DIR)
|
@mkdir -p $(INSTALL_BIN_DIR)
|
||||||
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
# Copy binary with temporary suffix to ensure atomic update
|
||||||
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||||
|
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX)
|
||||||
|
@mv -f $(INSTALL_BIN_DIR)/$(BINARY_NAME)$(INSTALL_TMP_SUFFIX) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
|
||||||
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
|
||||||
@echo "Installation complete!"
|
@echo "Installation complete!"
|
||||||
|
|
||||||
@@ -141,6 +183,10 @@ fmt:
|
|||||||
lint:
|
lint:
|
||||||
@$(GOLANGCI_LINT) run
|
@$(GOLANGCI_LINT) run
|
||||||
|
|
||||||
|
## fix: Fix linting issues
|
||||||
|
fix:
|
||||||
|
@$(GOLANGCI_LINT) run --fix
|
||||||
|
|
||||||
## deps: Download dependencies
|
## deps: Download dependencies
|
||||||
deps:
|
deps:
|
||||||
@$(GO) mod download
|
@$(GO) mod download
|
||||||
@@ -204,7 +250,7 @@ help:
|
|||||||
@echo " make [target]"
|
@echo " make [target]"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
@grep -E '^## ' $(MAKEFILE_LIST) | sed 's/## / /'
|
@grep -E '^## ' $(MAKEFILE_LIST) | sort | awk -F': ' '{printf " %-16s %s\n", substr($$1, 4), $$2}'
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Examples:"
|
@echo "Examples:"
|
||||||
@echo " make build # Build for current platform"
|
@echo " make build # Build for current platform"
|
||||||
|
|||||||
+39
-16
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
## 📢 Actualités
|
## 📢 Actualités
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/picoclaw_community_roadmap_260216.md) — nous avons hâte de vous accueillir !
|
2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir !
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
|
2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
|
||||||
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
|
🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
|
||||||
@@ -164,35 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configurez vos clés API
|
# 2. Premier lancement — génère docker/data/config.json puis s'arrête
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
|
# Le conteneur affiche "First-run setup complete." puis s'arrête.
|
||||||
|
|
||||||
# 3. Compiler & Démarrer
|
# 3. Configurez vos clés API
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc.
|
||||||
|
|
||||||
# 4. Voir les logs
|
# 4. Démarrer
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. Arrêter
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Voir les logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. Arrêter
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mode Agent (exécution unique)
|
### Mode Agent (exécution unique)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Poser une question
|
# Poser une question
|
||||||
docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?"
|
||||||
|
|
||||||
# Mode interactif
|
# Mode interactif
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Recompiler
|
### Mettre à jour
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Démarrage Rapide
|
### 🚀 Démarrage Rapide
|
||||||
@@ -217,12 +225,13 @@ picoclaw onboard
|
|||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt4"
|
"model_name": "gpt4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -248,6 +257,9 @@ picoclaw onboard
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails.
|
||||||
|
> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s).
|
||||||
|
|
||||||
**3. Obtenir des Clés API**
|
**3. Obtenir des Clés API**
|
||||||
|
|
||||||
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
@@ -975,6 +987,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
|
|||||||
```
|
```
|
||||||
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personnalisée**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Équilibrage de Charge
|
#### Équilibrage de Charge
|
||||||
|
|
||||||
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
|
||||||
|
|||||||
+63
-19
@@ -126,35 +126,43 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. API キーを設定
|
# 2. 初回起動 — docker/data/config.json を自動生成して終了
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
|
# コンテナが "First-run setup complete." を表示して停止します。
|
||||||
|
|
||||||
# 3. ビルドと起動
|
# 3. API キーを設定
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
|
||||||
|
|
||||||
# 4. ログ確認
|
# 4. 起動
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. 停止
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. ログ確認
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. 停止
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent モード(ワンショット)
|
### Agent モード(ワンショット)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 質問を投げる
|
# 質問を投げる
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# インタラクティブモード
|
# インタラクティブモード
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### リビルド
|
### アップデート
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 クイックスタート(ネイティブ)
|
### 🚀 クイックスタート(ネイティブ)
|
||||||
@@ -162,7 +170,7 @@ docker compose --profile gateway up -d
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> `~/.picoclaw/config.json` に API キーを設定してください。
|
> `~/.picoclaw/config.json` に API キーを設定してください。
|
||||||
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
|
||||||
|
|
||||||
**1. 初期化**
|
**1. 初期化**
|
||||||
|
|
||||||
@@ -179,12 +187,13 @@ picoclaw onboard
|
|||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt4"
|
"model_name": "gpt4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -193,14 +202,37 @@ picoclaw onboard
|
|||||||
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
"token": "YOUR_TELEGRAM_BOT_TOKEN",
|
||||||
"allow_from": []
|
"allow_from": []
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"tools": {
|
||||||
|
"web": {
|
||||||
|
"search": {
|
||||||
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_TAVILY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cron": {
|
||||||
|
"exec_timeout_minutes": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"heartbeat": {
|
||||||
|
"enabled": true,
|
||||||
|
"interval": 30
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
|
||||||
|
> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。
|
||||||
|
|
||||||
**3. API キーの取得**
|
**3. API キーの取得**
|
||||||
|
|
||||||
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) · [Qwen](https://dashscope.console.aliyun.com)
|
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
- **Web 検索**(任意): [Tavily](https://tavily.com) - AI エージェント向けに最適化 (月 1000 リクエスト) · [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
|
||||||
|
|
||||||
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
|
||||||
|
|
||||||
@@ -894,6 +926,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
|
|||||||
```
|
```
|
||||||
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
|
||||||
|
|
||||||
|
**カスタムプロキシ/API**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### ロードバランシング
|
#### ロードバランシング
|
||||||
|
|
||||||
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
|
||||||
@@ -985,7 +1028,7 @@ Discord: https://discord.gg/V4sAZ9XWpN
|
|||||||
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
|
||||||
|
|
||||||
Web 検索を有効にするには:
|
Web 検索を有効にするには:
|
||||||
1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
1. [https://tavily.com](https://tavily.com) (月 1000 クエリ無料) または [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
|
||||||
2. `~/.picoclaw/config.json` に追加:
|
2. `~/.picoclaw/config.json` に追加:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -1023,5 +1066,6 @@ Web 検索を有効にするには:
|
|||||||
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
|
||||||
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
|
||||||
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
|
||||||
|
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
|
||||||
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
|
||||||
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
|
||||||
|
|||||||
@@ -12,9 +12,13 @@
|
|||||||
<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://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
|
||||||
|
<br>
|
||||||
|
<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) | [Français](README.fr.md) | **English**
|
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -42,16 +46,17 @@
|
|||||||
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
|
||||||
>
|
>
|
||||||
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
|
||||||
|
>
|
||||||
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
|
||||||
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
|
||||||
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
|
||||||
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
> * **Note:** picoclaw has recently merged a lot of PRs, which may result in a larger memory footprint (10–20MB) in the latest versions. We plan to prioritize resource optimization as soon as the current feature set reaches a stable state.
|
||||||
|
|
||||||
|
|
||||||
## 📢 News
|
## 📢 News
|
||||||
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](docs/picoclaw_community_roadmap_260216.md) —we can’t 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 come 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-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](docs/ROADMAP.md) —we can’t 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.
|
||||||
🚀 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. 🦐 PicoClaw,Let's Go!
|
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
|
||||||
@@ -100,9 +105,12 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
### 📱 Run on old Android Phones
|
### 📱 Run on old Android Phones
|
||||||
|
|
||||||
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start:
|
||||||
|
|
||||||
1. **Install Termux** (Available on F-Droid or Google Play).
|
1. **Install Termux** (Available on F-Droid or Google Play).
|
||||||
2. **Execute cmds**
|
2. **Execute cmds**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Note: Replace v0.1.1 with the latest version from the Releases page
|
# Note: Replace v0.1.1 with the latest version from the Releases page
|
||||||
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
|
||||||
@@ -110,6 +118,7 @@ chmod +x picoclaw-linux-arm64
|
|||||||
pkg install proot
|
pkg install proot
|
||||||
termux-chroot ./picoclaw-linux-arm64 onboard
|
termux-chroot ./picoclaw-linux-arm64 onboard
|
||||||
```
|
```
|
||||||
|
|
||||||
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
And then follow the instructions in the "Quick Start" section to complete the configuration!
|
||||||
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
<img src="assets/termux.jpg" alt="PicoClaw" width="512">
|
||||||
|
|
||||||
@@ -145,10 +154,15 @@ make build
|
|||||||
# Build for multiple platforms
|
# Build for multiple platforms
|
||||||
make build-all
|
make build-all
|
||||||
|
|
||||||
|
# Build for Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
|
||||||
|
make build-pi-zero
|
||||||
|
|
||||||
# Build And Install
|
# Build And Install
|
||||||
make install
|
make install
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Raspberry Pi Zero 2 W:** Use the binary that matches your OS: 32-bit Raspberry Pi OS → `make build-linux-arm` (output: `build/picoclaw-linux-arm`); 64-bit → `make build-linux-arm64` (output: `build/picoclaw-linux-arm64`). Or run `make build-pi-zero` to build both.
|
||||||
|
|
||||||
## 🐳 Docker Compose
|
## 🐳 Docker Compose
|
||||||
|
|
||||||
You can also run PicoClaw using Docker Compose without installing anything locally.
|
You can also run PicoClaw using Docker Compose without installing anything locally.
|
||||||
@@ -158,35 +172,43 @@ You can also run PicoClaw using Docker Compose without installing anything local
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Set your API keys
|
# 2. First run — auto-generates docker/data/config.json then exits
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
|
# The container prints "First-run setup complete." and stops.
|
||||||
|
|
||||||
# 3. Build & Start
|
# 3. Set your API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Set provider API keys, bot tokens, etc.
|
||||||
|
|
||||||
# 4. Check logs
|
# 4. Start
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. Stop
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Check logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. Stop
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent Mode (One-shot)
|
### Agent Mode (One-shot)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Ask a question
|
# Ask a question
|
||||||
docker compose run --rm picoclaw-agent -m "What is 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
|
||||||
|
|
||||||
# Interactive mode
|
# Interactive mode
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Update
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Quick Start
|
### 🚀 Quick Start
|
||||||
@@ -194,7 +216,7 @@ docker compose --profile gateway up -d
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> Set your API key in `~/.picoclaw/config.json`.
|
> Set your API key in `~/.picoclaw/config.json`.
|
||||||
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> 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 [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
|
||||||
|
|
||||||
**1. Initialize**
|
**1. Initialize**
|
||||||
|
|
||||||
@@ -209,7 +231,7 @@ picoclaw onboard
|
|||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model": "gpt4",
|
"model_name": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
@@ -219,7 +241,8 @@ picoclaw onboard
|
|||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
@@ -234,6 +257,11 @@ picoclaw onboard
|
|||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"tavily": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_TAVILY_API_KEY",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
"duckduckgo": {
|
"duckduckgo": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
@@ -244,11 +272,12 @@ picoclaw onboard
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
|
||||||
|
> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
|
||||||
|
|
||||||
**3. Get API Keys**
|
**3. Get API Keys**
|
||||||
|
|
||||||
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
* **Web Search** (optional): [Tavily](https://tavily.com) - Optimized for AI Agents (1000 requests/month) · [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
|
||||||
|
|
||||||
> **Note**: See `config.example.json` for a complete configuration template.
|
> **Note**: See `config.example.json` for a complete configuration template.
|
||||||
|
|
||||||
@@ -264,12 +293,13 @@ That's it! You have a working AI assistant in 2 minutes.
|
|||||||
|
|
||||||
## 💬 Chat Apps
|
## 💬 Chat Apps
|
||||||
|
|
||||||
Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
|
Talk to your picoclaw through Telegram, Discord, WhatsApp, DingTalk, LINE, or WeCom
|
||||||
|
|
||||||
| Channel | Setup |
|
| Channel | Setup |
|
||||||
| ------------ | ---------------------------------- |
|
| ------------ | ---------------------------------- |
|
||||||
| **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) |
|
||||||
| **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) |
|
||||||
@@ -323,7 +353,6 @@ picoclaw gateway
|
|||||||
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
|
||||||
|
|
||||||
**3. Get your User ID**
|
**3. Get your User ID**
|
||||||
|
|
||||||
* Discord Settings → Advanced → enable **Developer Mode**
|
* Discord Settings → Advanced → enable **Developer Mode**
|
||||||
* Right-click your avatar → **Copy User ID**
|
* Right-click your avatar → **Copy User ID**
|
||||||
|
|
||||||
@@ -361,6 +390,33 @@ picoclaw gateway
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><b>WhatsApp</b> (native via whatsmeow)</summary>
|
||||||
|
|
||||||
|
PicoClaw can connect to WhatsApp in two ways:
|
||||||
|
|
||||||
|
- **Native (recommended):** In-process using [whatsmeow](https://github.com/tulir/whatsmeow). No separate bridge. Set `"use_native": true` and leave `bridge_url` empty. On first run, scan the QR code with WhatsApp (Linked Devices). Session is stored under your workspace (e.g. `workspace/whatsapp/`). The native channel is **optional** to keep the default binary small; build with `-tags whatsapp_native` (e.g. `make build-whatsapp-native` or `go build -tags whatsapp_native ./cmd/...`).
|
||||||
|
- **Bridge:** Connect to an external WebSocket bridge. Set `bridge_url` (e.g. `ws://localhost:3001`) and keep `use_native` false.
|
||||||
|
|
||||||
|
**Configure (native)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"whatsapp": {
|
||||||
|
"enabled": true,
|
||||||
|
"use_native": true,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>QQ</b></summary>
|
<summary><b>QQ</b></summary>
|
||||||
|
|
||||||
@@ -425,7 +481,6 @@ picoclaw gateway
|
|||||||
```bash
|
```bash
|
||||||
picoclaw gateway
|
picoclaw gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -521,7 +576,6 @@ See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detaile
|
|||||||
* Go to WeCom Admin Console → App Management → Create App
|
* Go to WeCom Admin Console → App Management → Create App
|
||||||
* Copy **AgentId** and **Secret**
|
* Copy **AgentId** and **Secret**
|
||||||
* Go to "My Company" page, copy **CorpID**
|
* Go to "My Company" page, copy **CorpID**
|
||||||
|
|
||||||
**2. Configure receive message**
|
**2. Configure receive message**
|
||||||
|
|
||||||
* In App details, click "Receive Message" → "Set API"
|
* In App details, click "Receive Message" → "Set API"
|
||||||
@@ -606,7 +660,7 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ----------------------- | ----------------------- | ----------------------------------------- |
|
||||||
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent |
|
||||||
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
| `restrict_to_workspace` | `true` | Restrict file/command access to workspace |
|
||||||
|
|
||||||
@@ -615,7 +669,7 @@ PicoClaw runs in a sandboxed environment by default. The agent can only access f
|
|||||||
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
When `restrict_to_workspace: true`, the following tools are sandboxed:
|
||||||
|
|
||||||
| Tool | Function | Restriction |
|
| Tool | Function | Restriction |
|
||||||
|------|----------|-------------|
|
| ------------- | ---------------- | -------------------------------------- |
|
||||||
| `read_file` | Read files | Only files within workspace |
|
| `read_file` | Read files | Only files within workspace |
|
||||||
| `write_file` | Write files | Only files within workspace |
|
| `write_file` | Write files | Only files within workspace |
|
||||||
| `list_dir` | List directories | Only directories within workspace |
|
| `list_dir` | List directories | Only directories within workspace |
|
||||||
@@ -675,7 +729,7 @@ export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
|
|||||||
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
The `restrict_to_workspace` setting applies consistently across all execution paths:
|
||||||
|
|
||||||
| Execution Path | Security Boundary |
|
| Execution Path | Security Boundary |
|
||||||
|----------------|-------------------|
|
| ---------------- | ---------------------------- |
|
||||||
| Main Agent | `restrict_to_workspace` ✅ |
|
| Main Agent | `restrict_to_workspace` ✅ |
|
||||||
| Subagent / Spawn | Inherits same restriction ✅ |
|
| Subagent / Spawn | Inherits same restriction ✅ |
|
||||||
| Heartbeat tasks | Inherits same restriction ✅ |
|
| Heartbeat tasks | Inherits same restriction ✅ |
|
||||||
@@ -704,9 +758,11 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
|||||||
# Periodic Tasks
|
# Periodic Tasks
|
||||||
|
|
||||||
## Quick Tasks (respond directly)
|
## Quick Tasks (respond directly)
|
||||||
|
|
||||||
- Report current time
|
- Report current time
|
||||||
|
|
||||||
## Long Tasks (use spawn for async)
|
## Long Tasks (use spawn for async)
|
||||||
|
|
||||||
- Search the web for AI news and summarize
|
- Search the web for AI news and summarize
|
||||||
- Check email and report important messages
|
- Check email and report important messages
|
||||||
```
|
```
|
||||||
@@ -714,7 +770,7 @@ For long-running tasks (web search, API calls), use the `spawn` tool to create a
|
|||||||
**Key behaviors:**
|
**Key behaviors:**
|
||||||
|
|
||||||
| Feature | Description |
|
| Feature | Description |
|
||||||
|---------|-------------|
|
| ----------------------- | --------------------------------------------------------- |
|
||||||
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
| **spawn** | Creates async subagent, doesn't block heartbeat |
|
||||||
| **Independent context** | Subagent has its own context, no session history |
|
| **Independent context** | Subagent has its own context, no session history |
|
||||||
| **message tool** | Subagent communicates with user directly via message tool |
|
| **message tool** | Subagent communicates with user directly via message tool |
|
||||||
@@ -750,7 +806,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
|--------|---------|-------------|
|
| ---------- | ------- | ---------------------------------- |
|
||||||
| `enabled` | `true` | Enable/disable heartbeat |
|
| `enabled` | `true` | Enable/disable heartbeat |
|
||||||
| `interval` | `30` | Check interval in minutes (min: 5) |
|
| `interval` | `30` | Check interval in minutes (min: 5) |
|
||||||
|
|
||||||
@@ -765,9 +821,9 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
|
|||||||
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed.
|
||||||
|
|
||||||
| 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](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) |
|
| `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) |
|
||||||
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
|
||||||
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
|
||||||
@@ -790,7 +846,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
#### 📋 All Supported Vendors
|
#### 📋 All Supported Vendors
|
||||||
|
|
||||||
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
|
||||||
|--------|----------------|------------------|----------|---------|
|
| ------------------- | ----------------- | --------------------------------------------------- | --------- | ---------------------------------------------------------------- |
|
||||||
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
|
||||||
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
|
||||||
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
|
||||||
@@ -841,6 +897,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
#### Vendor-Specific Examples
|
#### Vendor-Specific Examples
|
||||||
|
|
||||||
**OpenAI**
|
**OpenAI**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "gpt-5.2",
|
"model_name": "gpt-5.2",
|
||||||
@@ -850,6 +907,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
```
|
```
|
||||||
|
|
||||||
**智谱 AI (GLM)**
|
**智谱 AI (GLM)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "glm-4.7",
|
"model_name": "glm-4.7",
|
||||||
@@ -859,6 +917,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
```
|
```
|
||||||
|
|
||||||
**DeepSeek**
|
**DeepSeek**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "deepseek-chat",
|
"model_name": "deepseek-chat",
|
||||||
@@ -868,6 +927,7 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Anthropic (with API key)**
|
**Anthropic (with API key)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
@@ -875,9 +935,11 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
"api_key": "sk-ant-your-key"
|
"api_key": "sk-ant-your-key"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
> Run `picoclaw auth login --provider anthropic` to paste your API token.
|
||||||
|
|
||||||
**Ollama (local)**
|
**Ollama (local)**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "llama3",
|
"model_name": "llama3",
|
||||||
@@ -886,12 +948,14 @@ This design also enables **multi-agent support** with flexible provider selectio
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Custom Proxy/API**
|
**Custom Proxy/API**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -923,6 +987,7 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
|
|||||||
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
The old `providers` configuration is **deprecated** but still supported for backward compatibility.
|
||||||
|
|
||||||
**Old Config (deprecated):**
|
**Old Config (deprecated):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"providers": {
|
"providers": {
|
||||||
@@ -941,6 +1006,7 @@ The old `providers` configuration is **deprecated** but still supported for back
|
|||||||
```
|
```
|
||||||
|
|
||||||
**New Config (recommended):**
|
**New Config (recommended):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"model_list": [
|
"model_list": [
|
||||||
@@ -1037,7 +1103,11 @@ picoclaw agent -m "Hello"
|
|||||||
"allow_from": [""]
|
"allow_from": [""]
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false
|
"enabled": false,
|
||||||
|
"bridge_url": "ws://localhost:3001",
|
||||||
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": []
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -1105,9 +1175,9 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
|
|||||||
|
|
||||||
PRs welcome! The codebase is intentionally small and readable. 🤗
|
PRs welcome! The codebase is intentionally small and readable. 🤗
|
||||||
|
|
||||||
Roadmap coming soon...
|
See our full [Community Roadmap](https://github.com/sipeed/picoclaw/blob/main/ROADMAP.md).
|
||||||
|
|
||||||
Developer group building, Entry Requirement: At least 1 Merged PR.
|
Developer group building, join after your first merged PR!
|
||||||
|
|
||||||
User Groups:
|
User Groups:
|
||||||
|
|
||||||
@@ -1117,7 +1187,7 @@ discord: <https://discord.gg/V4sAZ9XWpN>
|
|||||||
|
|
||||||
## 🐛 Troubleshooting
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
### Web search says "API 配置问题"
|
### Web search says "API key configuration issue"
|
||||||
|
|
||||||
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
This is normal if you haven't configured a search API key yet. PicoClaw will provide helpful links for manual searching.
|
||||||
|
|
||||||
|
|||||||
+39
-16
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
## 📢 Novidades
|
## 📢 Novidades
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter você a bordo!
|
2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo!
|
||||||
|
|
||||||
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
|
2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
|
||||||
|
|
||||||
@@ -165,35 +165,43 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Configure suas API keys
|
# 2. Primeiro uso — gera docker/data/config.json automaticamente e para
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
|
# O contêiner exibe "First-run setup complete." e para.
|
||||||
|
|
||||||
# 3. Build & Iniciar
|
# 3. Configure suas API keys
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc.
|
||||||
|
|
||||||
# 4. Ver logs
|
# 4. Iniciar
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. Parar
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Ver logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. Parar
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Modo Agente (Execução única)
|
### Modo Agente (Execução única)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Fazer uma pergunta
|
# Fazer uma pergunta
|
||||||
docker compose run --rm picoclaw-agent -m "Quanto e 2+2?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?"
|
||||||
|
|
||||||
# Modo interativo
|
# Modo interativo
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rebuild
|
### Atualizar
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Início Rápido
|
### 🚀 Início Rápido
|
||||||
@@ -218,12 +226,13 @@ picoclaw onboard
|
|||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt4"
|
"model_name": "gpt4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -242,6 +251,9 @@ picoclaw onboard
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes.
|
||||||
|
> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).
|
||||||
|
|
||||||
**3. Obter API Keys**
|
**3. Obter API Keys**
|
||||||
|
|
||||||
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
@@ -969,6 +981,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
|
|||||||
```
|
```
|
||||||
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
|
||||||
|
|
||||||
|
**Proxy/API personalizada**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Balanceamento de Carga
|
#### Balanceamento de Carga
|
||||||
|
|
||||||
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
|
||||||
|
|||||||
+39
-16
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
## 📢 Tin tức
|
## 📢 Tin tức
|
||||||
|
|
||||||
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/picoclaw_community_roadmap_260216.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.
|
||||||
@@ -145,35 +145,43 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. Thiết lập API Key
|
# 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
|
# Container hiển thị "First-run setup complete." rồi tự dừng.
|
||||||
|
|
||||||
# 3. Build & Khởi động
|
# 3. Thiết lập API Key
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # API key của provider, bot token, v.v.
|
||||||
|
|
||||||
# 4. Xem logs
|
# 4. Khởi động
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. Dừng
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. Xem logs
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. Dừng
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Chế độ Agent (chạy một lần)
|
### Chế độ Agent (chạy một lần)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Đặt câu hỏi
|
# Đặt câu hỏi
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?"
|
||||||
|
|
||||||
# Chế độ tương tác
|
# Chế độ tương tác
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
### Build lại
|
### Cập nhật
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 Bắt đầu nhanh
|
### 🚀 Bắt đầu nhanh
|
||||||
@@ -198,12 +206,13 @@ picoclaw onboard
|
|||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "sk-your-openai-key",
|
"api_key": "sk-your-openai-key",
|
||||||
|
"request_timeout": 300,
|
||||||
"api_base": "https://api.openai.com/v1"
|
"api_base": "https://api.openai.com/v1"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"model": "gpt4"
|
"model_name": "gpt4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"channels": {
|
"channels": {
|
||||||
@@ -216,6 +225,9 @@ picoclaw onboard
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.
|
||||||
|
> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s).
|
||||||
|
|
||||||
**3. Lấy API Key**
|
**3. Lấy API Key**
|
||||||
|
|
||||||
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
@@ -940,6 +952,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
|
|||||||
```
|
```
|
||||||
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
|
||||||
|
|
||||||
|
**Proxy/API tùy chỉnh**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model_name": "my-custom-model",
|
||||||
|
"model": "openai/custom-model",
|
||||||
|
"api_base": "https://my-proxy.com/v1",
|
||||||
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Cân bằng Tải tải
|
#### Cân bằng Tải tải
|
||||||
|
|
||||||
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
|
||||||
|
|||||||
+37
-28
@@ -52,7 +52,7 @@
|
|||||||
|
|
||||||
## 📢 新闻 (News)
|
## 📢 新闻 (News)
|
||||||
|
|
||||||
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
|
2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
|
||||||
|
|
||||||
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
|
||||||
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
|
||||||
@@ -166,38 +166,43 @@ make install
|
|||||||
git clone https://github.com/sipeed/picoclaw.git
|
git clone https://github.com/sipeed/picoclaw.git
|
||||||
cd picoclaw
|
cd picoclaw
|
||||||
|
|
||||||
# 2. 设置 API Key
|
# 2. 首次运行 — 自动生成 docker/data/config.json 后退出
|
||||||
cp config/config.example.json config/config.json
|
docker compose -f docker/docker-compose.yml --profile gateway up
|
||||||
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
|
# 容器打印 "First-run setup complete." 后自动停止
|
||||||
|
|
||||||
# 3. 构建并启动
|
# 3. 填写 API Key 等配置
|
||||||
docker compose --profile gateway up -d
|
vim docker/data/config.json # 设置 provider API key、Bot Token 等
|
||||||
|
|
||||||
# 4. 查看日志
|
# 4. 正式启动
|
||||||
docker compose logs -f picoclaw-gateway
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
```
|
||||||
|
|
||||||
# 5. 停止
|
> [!TIP]
|
||||||
docker compose --profile gateway down
|
> **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 5. 查看日志
|
||||||
|
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
|
||||||
|
|
||||||
|
# 6. 停止
|
||||||
|
docker compose -f docker/docker-compose.yml --profile gateway down
|
||||||
```
|
```
|
||||||
|
|
||||||
### Agent 模式 (一次性运行)
|
### Agent 模式 (一次性运行)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 提问
|
# 提问
|
||||||
docker compose run --rm picoclaw-agent -m "2+2 等于几?"
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?"
|
||||||
|
|
||||||
# 交互模式
|
# 交互模式
|
||||||
docker compose run --rm picoclaw-agent
|
docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 重新构建
|
### 更新镜像
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile gateway build --no-cache
|
docker compose -f docker/docker-compose.yml pull
|
||||||
docker compose --profile gateway up -d
|
docker compose -f docker/docker-compose.yml --profile gateway up -d
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 快速开始
|
### 🚀 快速开始
|
||||||
@@ -205,7 +210,7 @@ docker compose --profile gateway up -d
|
|||||||
> [!TIP]
|
> [!TIP]
|
||||||
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
|
||||||
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
|
||||||
> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
|
||||||
|
|
||||||
**1. 初始化 (Initialize)**
|
**1. 初始化 (Initialize)**
|
||||||
|
|
||||||
@@ -221,7 +226,7 @@ picoclaw onboard
|
|||||||
"agents": {
|
"agents": {
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"model": "gpt4",
|
"model_name": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
@@ -231,7 +236,8 @@ picoclaw onboard
|
|||||||
{
|
{
|
||||||
"model_name": "gpt4",
|
"model_name": "gpt4",
|
||||||
"model": "openai/gpt-5.2",
|
"model": "openai/gpt-5.2",
|
||||||
"api_key": "your-api-key"
|
"api_key": "your-api-key",
|
||||||
|
"request_timeout": 300
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"model_name": "claude-sonnet-4.6",
|
"model_name": "claude-sonnet-4.6",
|
||||||
@@ -246,8 +252,9 @@ picoclaw onboard
|
|||||||
"api_key": "YOUR_BRAVE_API_KEY",
|
"api_key": "YOUR_BRAVE_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
"duckduckgo": {
|
"tavily": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
|
"api_key": "YOUR_TAVILY_API_KEY",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -259,11 +266,12 @@ picoclaw onboard
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
|
||||||
|
> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
|
||||||
|
|
||||||
**3. 获取 API Key**
|
**3. 获取 API Key**
|
||||||
|
|
||||||
- **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
|
||||||
- **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
|
||||||
|
|
||||||
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
> **注意**: 完整的配置模板请参考 `config.example.json`。
|
||||||
|
|
||||||
@@ -546,7 +554,8 @@ Agent 读取 HEARTBEAT.md
|
|||||||
"model_name": "my-custom-model",
|
"model_name": "my-custom-model",
|
||||||
"model": "openai/custom-model",
|
"model": "openai/custom-model",
|
||||||
"api_base": "https://my-proxy.com/v1",
|
"api_base": "https://my-proxy.com/v1",
|
||||||
"api_key": "sk-..."
|
"api_key": "sk-...",
|
||||||
|
"request_timeout": 300
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -771,7 +780,7 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||||||
|
|
||||||
启用网络搜索:
|
启用网络搜索:
|
||||||
|
|
||||||
1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
|
1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
|
||||||
2. 添加到 `~/.picoclaw/config.json`:
|
2. 添加到 `~/.picoclaw/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -805,9 +814,9 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
|
|||||||
## 📝 API Key 对比
|
## 📝 API Key 对比
|
||||||
|
|
||||||
| 服务 | 免费层级 | 适用场景 |
|
| 服务 | 免费层级 | 适用场景 |
|
||||||
| ---------------- | -------------- | ----------------------------- |
|
| --- | --- | --- |
|
||||||
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
|
||||||
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
|
||||||
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
|
||||||
|
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
|
||||||
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
|
||||||
| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 366 KiB |
@@ -1,227 +0,0 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
|
||||||
)
|
|
||||||
|
|
||||||
func cronCmd() {
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
cronHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subcommand := os.Args[2]
|
|
||||||
|
|
||||||
// Load config to get workspace path
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
|
||||||
|
|
||||||
switch subcommand {
|
|
||||||
case "list":
|
|
||||||
cronListCmd(cronStorePath)
|
|
||||||
case "add":
|
|
||||||
cronAddCmd(cronStorePath)
|
|
||||||
case "remove":
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw cron remove <job_id>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
cronRemoveCmd(cronStorePath, os.Args[3])
|
|
||||||
case "enable":
|
|
||||||
cronEnableCmd(cronStorePath, false)
|
|
||||||
case "disable":
|
|
||||||
cronEnableCmd(cronStorePath, true)
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown cron command: %s\n", subcommand)
|
|
||||||
cronHelp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronHelp() {
|
|
||||||
fmt.Println("\nCron commands:")
|
|
||||||
fmt.Println(" list List all scheduled jobs")
|
|
||||||
fmt.Println(" add Add a new scheduled job")
|
|
||||||
fmt.Println(" remove <id> Remove a job by ID")
|
|
||||||
fmt.Println(" enable <id> Enable a job")
|
|
||||||
fmt.Println(" disable <id> Disable a job")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Add options:")
|
|
||||||
fmt.Println(" -n, --name Job name")
|
|
||||||
fmt.Println(" -m, --message Message for agent")
|
|
||||||
fmt.Println(" -e, --every Run every N seconds")
|
|
||||||
fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
|
|
||||||
fmt.Println(" -d, --deliver Deliver response to channel")
|
|
||||||
fmt.Println(" --to Recipient for delivery")
|
|
||||||
fmt.Println(" --channel Channel for delivery")
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronListCmd(storePath string) {
|
|
||||||
cs := cron.NewCronService(storePath, nil)
|
|
||||||
jobs := cs.ListJobs(true) // Show all jobs, including disabled
|
|
||||||
|
|
||||||
if len(jobs) == 0 {
|
|
||||||
fmt.Println("No scheduled jobs.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("\nScheduled Jobs:")
|
|
||||||
fmt.Println("----------------")
|
|
||||||
for _, job := range jobs {
|
|
||||||
var schedule string
|
|
||||||
if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
|
|
||||||
schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
|
|
||||||
} else if job.Schedule.Kind == "cron" {
|
|
||||||
schedule = job.Schedule.Expr
|
|
||||||
} else {
|
|
||||||
schedule = "one-time"
|
|
||||||
}
|
|
||||||
|
|
||||||
nextRun := "scheduled"
|
|
||||||
if job.State.NextRunAtMS != nil {
|
|
||||||
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
|
|
||||||
nextRun = nextTime.Format("2006-01-02 15:04")
|
|
||||||
}
|
|
||||||
|
|
||||||
status := "enabled"
|
|
||||||
if !job.Enabled {
|
|
||||||
status = "disabled"
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
|
|
||||||
fmt.Printf(" Schedule: %s\n", schedule)
|
|
||||||
fmt.Printf(" Status: %s\n", status)
|
|
||||||
fmt.Printf(" Next run: %s\n", nextRun)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronAddCmd(storePath string) {
|
|
||||||
name := ""
|
|
||||||
message := ""
|
|
||||||
var everySec *int64
|
|
||||||
cronExpr := ""
|
|
||||||
deliver := false
|
|
||||||
channel := ""
|
|
||||||
to := ""
|
|
||||||
|
|
||||||
args := os.Args[3:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "-n", "--name":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
name = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-m", "--message":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
message = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-e", "--every":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
var sec int64
|
|
||||||
fmt.Sscanf(args[i+1], "%d", &sec)
|
|
||||||
everySec = &sec
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-c", "--cron":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
cronExpr = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-d", "--deliver":
|
|
||||||
deliver = true
|
|
||||||
case "--to":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
to = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--channel":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
channel = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if name == "" {
|
|
||||||
fmt.Println("Error: --name is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if message == "" {
|
|
||||||
fmt.Println("Error: --message is required")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if everySec == nil && cronExpr == "" {
|
|
||||||
fmt.Println("Error: Either --every or --cron must be specified")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var schedule cron.CronSchedule
|
|
||||||
if everySec != nil {
|
|
||||||
everyMS := *everySec * 1000
|
|
||||||
schedule = cron.CronSchedule{
|
|
||||||
Kind: "every",
|
|
||||||
EveryMS: &everyMS,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
schedule = cron.CronSchedule{
|
|
||||||
Kind: "cron",
|
|
||||||
Expr: cronExpr,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cs := cron.NewCronService(storePath, nil)
|
|
||||||
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error adding job: %v\n", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronRemoveCmd(storePath, jobID string) {
|
|
||||||
cs := cron.NewCronService(storePath, nil)
|
|
||||||
if cs.RemoveJob(jobID) {
|
|
||||||
fmt.Printf("✓ Removed job %s\n", jobID)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cronEnableCmd(storePath string, disable bool) {
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw cron enable/disable <job_id>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
jobID := os.Args[3]
|
|
||||||
cs := cron.NewCronService(storePath, nil)
|
|
||||||
enabled := !disable
|
|
||||||
|
|
||||||
job := cs.EnableJob(jobID, enabled)
|
|
||||||
if job != nil {
|
|
||||||
status := "enabled"
|
|
||||||
if disable {
|
|
||||||
status = "disabled"
|
|
||||||
}
|
|
||||||
fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
|
|
||||||
} else {
|
|
||||||
fmt.Printf("✗ Job %s not found\n", jobID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/migrate"
|
|
||||||
)
|
|
||||||
|
|
||||||
func migrateCmd() {
|
|
||||||
if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
|
|
||||||
migrateHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
opts := migrate.Options{}
|
|
||||||
|
|
||||||
args := os.Args[2:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--dry-run":
|
|
||||||
opts.DryRun = true
|
|
||||||
case "--config-only":
|
|
||||||
opts.ConfigOnly = true
|
|
||||||
case "--workspace-only":
|
|
||||||
opts.WorkspaceOnly = true
|
|
||||||
case "--force":
|
|
||||||
opts.Force = true
|
|
||||||
case "--refresh":
|
|
||||||
opts.Refresh = true
|
|
||||||
case "--openclaw-home":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
opts.OpenClawHome = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--picoclaw-home":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
opts.PicoClawHome = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown flag: %s\n", args[i])
|
|
||||||
migrateHelp()
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := migrate.Run(opts)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !opts.DryRun {
|
|
||||||
migrate.PrintSummary(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func migrateHelp() {
|
|
||||||
fmt.Println("\nMigrate from OpenClaw to PicoClaw")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Usage: picoclaw migrate [options]")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Options:")
|
|
||||||
fmt.Println(" --dry-run Show what would be migrated without making changes")
|
|
||||||
fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
|
|
||||||
fmt.Println(" --config-only Only migrate config, skip workspace files")
|
|
||||||
fmt.Println(" --workspace-only Only migrate workspace files, skip config")
|
|
||||||
fmt.Println(" --force Skip confirmation prompts")
|
|
||||||
fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
|
|
||||||
fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Examples:")
|
|
||||||
fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
|
|
||||||
fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
|
|
||||||
fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
|
|
||||||
fmt.Println(" picoclaw migrate --force Migrate without confirmation")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAgentCommand() *cobra.Command {
|
||||||
|
var (
|
||||||
|
message string
|
||||||
|
sessionKey string
|
||||||
|
model string
|
||||||
|
debug bool
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "agent",
|
||||||
|
Short: "Interact with the agent directly",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return agentCmd(message, sessionKey, model, debug)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
|
cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
|
||||||
|
cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
|
||||||
|
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAgentCommand(t *testing.T) {
|
||||||
|
cmd := NewAgentCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "agent", cmd.Use)
|
||||||
|
assert.Equal(t, "Interact with the agent directly", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("message"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("session"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("model"))
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package agent
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -14,62 +11,44 @@ import (
|
|||||||
|
|
||||||
"github.com/chzyer/readline"
|
"github.com/chzyer/readline"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func agentCmd() {
|
func agentCmd(message, sessionKey, model string, debug bool) error {
|
||||||
message := ""
|
if sessionKey == "" {
|
||||||
sessionKey := "cli:default"
|
sessionKey = "cli:default"
|
||||||
modelOverride := ""
|
}
|
||||||
|
|
||||||
args := os.Args[2:]
|
if debug {
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--debug", "-d":
|
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
case "-m", "--message":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
message = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "-s", "--session":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
sessionKey = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--model", "-model":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
modelOverride = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if modelOverride != "" {
|
if model != "" {
|
||||||
cfg.Agents.Defaults.Model = modelOverride
|
cfg.Agents.Defaults.ModelName = model
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, modelID, err := providers.CreateProvider(cfg)
|
provider, modelID, err := providers.CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating provider: %v\n", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the resolved model ID from provider creation
|
// Use the resolved model ID from provider creation
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
cfg.Agents.Defaults.Model = modelID
|
cfg.Agents.Defaults.ModelName = modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
|
defer msgBus.Close()
|
||||||
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
|
||||||
|
|
||||||
// Print agent startup info (only for interactive mode)
|
// Print agent startup info (only for interactive mode)
|
||||||
@@ -85,18 +64,20 @@ func agentCmd() {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
|
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error: %v\n", err)
|
return fmt.Errorf("error processing message: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
fmt.Printf("\n%s %s\n", logo, response)
|
fmt.Printf("\n%s %s\n", internal.Logo, response)
|
||||||
} else {
|
return nil
|
||||||
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
|
}
|
||||||
|
|
||||||
|
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", internal.Logo)
|
||||||
interactiveMode(agentLoop, sessionKey)
|
interactiveMode(agentLoop, sessionKey)
|
||||||
}
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
prompt := fmt.Sprintf("%s You: ", logo)
|
prompt := fmt.Sprintf("%s You: ", internal.Logo)
|
||||||
|
|
||||||
rl, err := readline.NewEx(&readline.Config{
|
rl, err := readline.NewEx(&readline.Config{
|
||||||
Prompt: prompt,
|
Prompt: prompt,
|
||||||
@@ -141,14 +122,14 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n%s %s\n\n", logo, response)
|
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
for {
|
for {
|
||||||
fmt.Print(fmt.Sprintf("%s You: ", logo))
|
fmt.Print(fmt.Sprintf("%s You: ", internal.Logo))
|
||||||
line, err := reader.ReadString('\n')
|
line, err := reader.ReadString('\n')
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == io.EOF {
|
if err == io.EOF {
|
||||||
@@ -176,6 +157,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\n%s %s\n\n", logo, response)
|
fmt.Printf("\n%s %s\n\n", internal.Logo, response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func NewAuthCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "auth",
|
||||||
|
Short: "Manage authentication (login, logout, status)",
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
newLoginCommand(),
|
||||||
|
newLogoutCommand(),
|
||||||
|
newStatusCommand(),
|
||||||
|
newModelsCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAuthCommand(t *testing.T) {
|
||||||
|
cmd := NewAuthCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "auth", cmd.Use)
|
||||||
|
assert.Equal(t, "Manage authentication (login, logout, status)", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
assert.True(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
allowedCommands := []string{
|
||||||
|
"login",
|
||||||
|
"logout",
|
||||||
|
"status",
|
||||||
|
"models",
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommands := cmd.Commands()
|
||||||
|
assert.Len(t, subcommands, len(allowedCommands))
|
||||||
|
|
||||||
|
for _, subcmd := range subcommands {
|
||||||
|
found := slices.Contains(allowedCommands, subcmd.Name())
|
||||||
|
assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
|
||||||
|
|
||||||
|
assert.Len(t, subcmd.Aliases, 0)
|
||||||
|
assert.False(t, subcmd.Hidden)
|
||||||
|
|
||||||
|
assert.False(t, subcmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.Nil(t, subcmd.Run)
|
||||||
|
assert.NotNil(t, subcmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, subcmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, subcmd.PersistentPostRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package auth
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
@@ -12,92 +9,28 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"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"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
)
|
)
|
||||||
|
|
||||||
const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
|
const supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
|
||||||
|
|
||||||
func authCmd() {
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
authHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch os.Args[2] {
|
|
||||||
case "login":
|
|
||||||
authLoginCmd()
|
|
||||||
case "logout":
|
|
||||||
authLogoutCmd()
|
|
||||||
case "status":
|
|
||||||
authStatusCmd()
|
|
||||||
case "models":
|
|
||||||
authModelsCmd()
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown auth command: %s\n", os.Args[2])
|
|
||||||
authHelp()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func authHelp() {
|
|
||||||
fmt.Println("\nAuth commands:")
|
|
||||||
fmt.Println(" login Login via OAuth or paste token")
|
|
||||||
fmt.Println(" logout Remove stored credentials")
|
|
||||||
fmt.Println(" status Show current auth status")
|
|
||||||
fmt.Println(" models List available Antigravity models")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Login options:")
|
|
||||||
fmt.Println(" --provider <name> Provider to login with (openai, anthropic, google-antigravity)")
|
|
||||||
fmt.Println(" --device-code Use device code flow (for headless environments)")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Examples:")
|
|
||||||
fmt.Println(" picoclaw auth login --provider openai")
|
|
||||||
fmt.Println(" picoclaw auth login --provider openai --device-code")
|
|
||||||
fmt.Println(" picoclaw auth login --provider anthropic")
|
|
||||||
fmt.Println(" picoclaw auth login --provider google-antigravity")
|
|
||||||
fmt.Println(" picoclaw auth models")
|
|
||||||
fmt.Println(" picoclaw auth logout --provider openai")
|
|
||||||
fmt.Println(" picoclaw auth status")
|
|
||||||
}
|
|
||||||
|
|
||||||
func authLoginCmd() {
|
|
||||||
provider := ""
|
|
||||||
useDeviceCode := false
|
|
||||||
|
|
||||||
args := os.Args[3:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--provider", "-p":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
provider = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
case "--device-code":
|
|
||||||
useDeviceCode = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if provider == "" {
|
|
||||||
fmt.Println("Error: --provider is required")
|
|
||||||
fmt.Println(supportedProvidersMsg)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
func authLoginCmd(provider string, useDeviceCode bool) error {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "openai":
|
case "openai":
|
||||||
authLoginOpenAI(useDeviceCode)
|
return authLoginOpenAI(useDeviceCode)
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
authLoginPasteToken(provider)
|
return authLoginPasteToken(provider)
|
||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
authLoginGoogleAntigravity()
|
return authLoginGoogleAntigravity()
|
||||||
default:
|
default:
|
||||||
fmt.Printf("Unsupported provider: %s\n", provider)
|
return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg)
|
||||||
fmt.Println(supportedProvidersMsg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginOpenAI(useDeviceCode bool) {
|
func authLoginOpenAI(useDeviceCode bool) error {
|
||||||
cfg := auth.OpenAIOAuthConfig()
|
cfg := auth.OpenAIOAuthConfig()
|
||||||
|
|
||||||
var cred *auth.AuthCredential
|
var cred *auth.AuthCredential
|
||||||
@@ -110,16 +43,14 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Login failed: %v\n", err)
|
return fmt.Errorf("login failed: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = auth.SetCredential("openai", cred); err != nil {
|
if err = auth.SetCredential("openai", cred); err != nil {
|
||||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Update Providers (legacy format)
|
// Update Providers (legacy format)
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
appCfg.Providers.OpenAI.AuthMethod = "oauth"
|
||||||
@@ -144,10 +75,10 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update default model to use OpenAI
|
// Update default model to use OpenAI
|
||||||
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||||
|
|
||||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,15 +87,16 @@ func authLoginOpenAI(useDeviceCode bool) {
|
|||||||
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.2")
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginGoogleAntigravity() {
|
func authLoginGoogleAntigravity() error {
|
||||||
cfg := auth.GoogleAntigravityOAuthConfig()
|
cfg := auth.GoogleAntigravityOAuthConfig()
|
||||||
|
|
||||||
cred, err := auth.LoginBrowser(cfg)
|
cred, err := auth.LoginBrowser(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Login failed: %v\n", err)
|
return fmt.Errorf("login failed: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cred.Provider = "google-antigravity"
|
cred.Provider = "google-antigravity"
|
||||||
@@ -189,11 +121,10 @@ func authLoginGoogleAntigravity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err = auth.SetCredential("google-antigravity", cred); err != nil {
|
if err = auth.SetCredential("google-antigravity", cred); err != nil {
|
||||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Update Providers (legacy format, for backward compatibility)
|
// Update Providers (legacy format, for backward compatibility)
|
||||||
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
appCfg.Providers.Antigravity.AuthMethod = "oauth"
|
||||||
@@ -218,9 +149,9 @@ func authLoginGoogleAntigravity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update default model
|
// Update default model
|
||||||
appCfg.Agents.Defaults.Model = "gemini-flash"
|
appCfg.Agents.Defaults.ModelName = "gemini-flash"
|
||||||
|
|
||||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
fmt.Printf("Warning: could not update config: %v\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,6 +159,8 @@ func authLoginGoogleAntigravity() {
|
|||||||
fmt.Println("\n✓ Google Antigravity login successful!")
|
fmt.Println("\n✓ Google Antigravity login successful!")
|
||||||
fmt.Println("Default model set to: gemini-flash")
|
fmt.Println("Default model set to: gemini-flash")
|
||||||
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
|
fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
func fetchGoogleUserEmail(accessToken string) (string, error) {
|
||||||
@@ -258,19 +191,17 @@ func fetchGoogleUserEmail(accessToken string) (string, error) {
|
|||||||
return userInfo.Email, nil
|
return userInfo.Email, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLoginPasteToken(provider string) {
|
func authLoginPasteToken(provider string) error {
|
||||||
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
cred, err := auth.LoginPasteToken(provider, os.Stdin)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Login failed: %v\n", err)
|
return fmt.Errorf("login failed: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = auth.SetCredential(provider, cred); err != nil {
|
if err = auth.SetCredential(provider, cred); err != nil {
|
||||||
fmt.Printf("Failed to save credentials: %v\n", err)
|
return fmt.Errorf("failed to save credentials: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
@@ -292,7 +223,7 @@ func authLoginPasteToken(provider string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Update default model
|
// Update default model
|
||||||
appCfg.Agents.Defaults.Model = "claude-sonnet-4.6"
|
appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
|
||||||
case "openai":
|
case "openai":
|
||||||
appCfg.Providers.OpenAI.AuthMethod = "token"
|
appCfg.Providers.OpenAI.AuthMethod = "token"
|
||||||
// Update ModelList
|
// Update ModelList
|
||||||
@@ -312,38 +243,29 @@ func authLoginPasteToken(provider string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Update default model
|
// Update default model
|
||||||
appCfg.Agents.Defaults.Model = "gpt-5.2"
|
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
|
||||||
}
|
}
|
||||||
if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
|
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
|
||||||
fmt.Printf("Warning: could not update config: %v\n", err)
|
return fmt.Errorf("could not update config: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Token saved for %s!\n", provider)
|
fmt.Printf("Token saved for %s!\n", provider)
|
||||||
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.Model)
|
|
||||||
|
if appCfg != nil {
|
||||||
|
fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authLogoutCmd() {
|
func authLogoutCmd(provider string) error {
|
||||||
provider := ""
|
|
||||||
|
|
||||||
args := os.Args[3:]
|
|
||||||
for i := 0; i < len(args); i++ {
|
|
||||||
switch args[i] {
|
|
||||||
case "--provider", "-p":
|
|
||||||
if i+1 < len(args) {
|
|
||||||
provider = args[i+1]
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if provider != "" {
|
if provider != "" {
|
||||||
if err := auth.DeleteCredential(provider); err != nil {
|
if err := auth.DeleteCredential(provider); err != nil {
|
||||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Clear AuthMethod in ModelList
|
// Clear AuthMethod in ModelList
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
@@ -371,17 +293,19 @@ func authLogoutCmd() {
|
|||||||
case "google-antigravity", "antigravity":
|
case "google-antigravity", "antigravity":
|
||||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||||
}
|
}
|
||||||
config.SaveConfig(getConfigPath(), appCfg)
|
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Logged out from %s\n", provider)
|
fmt.Printf("Logged out from %s\n", provider)
|
||||||
} else {
|
|
||||||
if err := auth.DeleteAllCredentials(); err != nil {
|
return nil
|
||||||
fmt.Printf("Failed to remove credentials: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
appCfg, err := loadConfig()
|
if err := auth.DeleteAllCredentials(); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove credentials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appCfg, err := internal.LoadConfig()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// Clear all AuthMethods in ModelList
|
// Clear all AuthMethods in ModelList
|
||||||
for i := range appCfg.ModelList {
|
for i := range appCfg.ModelList {
|
||||||
@@ -391,24 +315,24 @@ func authLogoutCmd() {
|
|||||||
appCfg.Providers.OpenAI.AuthMethod = ""
|
appCfg.Providers.OpenAI.AuthMethod = ""
|
||||||
appCfg.Providers.Anthropic.AuthMethod = ""
|
appCfg.Providers.Anthropic.AuthMethod = ""
|
||||||
appCfg.Providers.Antigravity.AuthMethod = ""
|
appCfg.Providers.Antigravity.AuthMethod = ""
|
||||||
config.SaveConfig(getConfigPath(), appCfg)
|
config.SaveConfig(internal.GetConfigPath(), appCfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Logged out from all providers")
|
fmt.Println("Logged out from all providers")
|
||||||
}
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authStatusCmd() {
|
func authStatusCmd() error {
|
||||||
store, err := auth.LoadStore()
|
store, err := auth.LoadStore()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading auth store: %v\n", err)
|
return fmt.Errorf("failed to load auth store: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(store.Credentials) == 0 {
|
if len(store.Credentials) == 0 {
|
||||||
fmt.Println("No authenticated providers.")
|
fmt.Println("No authenticated providers.")
|
||||||
fmt.Println("Run: picoclaw auth login --provider <name>")
|
fmt.Println("Run: picoclaw auth login --provider <name>")
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("\nAuthenticated Providers:")
|
fmt.Println("\nAuthenticated Providers:")
|
||||||
@@ -437,14 +361,16 @@ func authStatusCmd() {
|
|||||||
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func authModelsCmd() {
|
func authModelsCmd() error {
|
||||||
cred, err := auth.GetCredential("google-antigravity")
|
cred, err := auth.GetCredential("google-antigravity")
|
||||||
if err != nil || cred == nil {
|
if err != nil || cred == nil {
|
||||||
fmt.Println("Not logged in to Google Antigravity.")
|
return fmt.Errorf(
|
||||||
fmt.Println("Run: picoclaw auth login --provider google-antigravity")
|
"not logged in to Google Antigravity.\nrun: picoclaw auth login --provider google-antigravity",
|
||||||
return
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh token if needed
|
// Refresh token if needed
|
||||||
@@ -459,21 +385,18 @@ func authModelsCmd() {
|
|||||||
|
|
||||||
projectID := cred.ProjectID
|
projectID := cred.ProjectID
|
||||||
if projectID == "" {
|
if projectID == "" {
|
||||||
fmt.Println("No project ID stored. Try logging in again.")
|
return fmt.Errorf("no project id stored. Try logging in again")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Fetching models for project: %s\n\n", projectID)
|
fmt.Printf("Fetching models for project: %s\n\n", projectID)
|
||||||
|
|
||||||
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
|
models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error fetching models: %v\n", err)
|
return fmt.Errorf("error fetching models: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(models) == 0 {
|
if len(models) == 0 {
|
||||||
fmt.Println("No models available.")
|
return fmt.Errorf("no models available")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Available Antigravity Models:")
|
fmt.Println("Available Antigravity Models:")
|
||||||
@@ -489,6 +412,8 @@ func authModelsCmd() {
|
|||||||
}
|
}
|
||||||
fmt.Printf(" %s %s\n", status, name)
|
fmt.Printf(" %s %s\n", status, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// isAntigravityModel checks if a model string belongs to antigravity provider
|
// isAntigravityModel checks if a model string belongs to antigravity provider
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newLoginCommand() *cobra.Command {
|
||||||
|
var (
|
||||||
|
provider string
|
||||||
|
useDeviceCode bool
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "login",
|
||||||
|
Short: "Login via OAuth or paste token",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return authLoginCmd(provider, useDeviceCode)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)")
|
||||||
|
cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
|
||||||
|
_ = cmd.MarkFlagRequired("provider")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewLoginSubCommand(t *testing.T) {
|
||||||
|
cmd := newLoginCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "Login via OAuth or paste token", cmd.Short)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("device-code"))
|
||||||
|
|
||||||
|
providerFlag := cmd.Flags().Lookup("provider")
|
||||||
|
require.NotNil(t, providerFlag)
|
||||||
|
|
||||||
|
val, found := providerFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||||
|
require.True(t, found)
|
||||||
|
require.NotEmpty(t, val)
|
||||||
|
assert.Equal(t, "true", val[0])
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newLogoutCommand() *cobra.Command {
|
||||||
|
var provider string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "logout",
|
||||||
|
Short: "Remove stored credentials",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return authLogoutCmd(provider)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to logout from (openai, anthropic); empty = all")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewLogoutSubcommand(t *testing.T) {
|
||||||
|
cmd := newLogoutCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "Remove stored credentials", cmd.Short)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("provider"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newModelsCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "models",
|
||||||
|
Short: "Show available models",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
return authModelsCmd()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewModelsCommand(t *testing.T) {
|
||||||
|
cmd := newModelsCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "models", cmd.Use)
|
||||||
|
assert.Equal(t, "Show available models", cmd.Short)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newStatusCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Short: "Show current auth status",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return authStatusCmd()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewStatusSubcommand(t *testing.T) {
|
||||||
|
cmd := newStatusCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "Show current auth status", cmd.Short)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAddCommand(storePath func() string) *cobra.Command {
|
||||||
|
var (
|
||||||
|
name string
|
||||||
|
message string
|
||||||
|
every int64
|
||||||
|
cronExp string
|
||||||
|
deliver bool
|
||||||
|
channel string
|
||||||
|
to string
|
||||||
|
)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "add",
|
||||||
|
Short: "Add a new scheduled job",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
if every <= 0 && cronExp == "" {
|
||||||
|
return fmt.Errorf("either --every or --cron must be specified")
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedule cron.CronSchedule
|
||||||
|
if every > 0 {
|
||||||
|
everyMS := every * 1000
|
||||||
|
schedule = cron.CronSchedule{Kind: "every", EveryMS: &everyMS}
|
||||||
|
} else {
|
||||||
|
schedule = cron.CronSchedule{Kind: "cron", Expr: cronExp}
|
||||||
|
}
|
||||||
|
|
||||||
|
cs := cron.NewCronService(storePath(), nil)
|
||||||
|
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error adding job: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVarP(&name, "name", "n", "", "Job name")
|
||||||
|
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
|
||||||
|
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
|
||||||
|
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
|
||||||
|
cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
|
||||||
|
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
|
||||||
|
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")
|
||||||
|
|
||||||
|
_ = cmd.MarkFlagRequired("name")
|
||||||
|
_ = cmd.MarkFlagRequired("message")
|
||||||
|
cmd.MarkFlagsMutuallyExclusive("every", "cron")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewAddSubcommand(t *testing.T) {
|
||||||
|
fn := func() string { return "" }
|
||||||
|
cmd := newAddCommand(fn)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "add", cmd.Use)
|
||||||
|
assert.Equal(t, "Add a new scheduled job", cmd.Short)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("every"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("cron"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("deliver"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("to"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("channel"))
|
||||||
|
|
||||||
|
nameFlag := cmd.Flags().Lookup("name")
|
||||||
|
require.NotNil(t, nameFlag)
|
||||||
|
|
||||||
|
messageFlag := cmd.Flags().Lookup("message")
|
||||||
|
require.NotNil(t, messageFlag)
|
||||||
|
|
||||||
|
val, found := nameFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||||
|
require.True(t, found)
|
||||||
|
require.NotEmpty(t, val)
|
||||||
|
assert.Equal(t, "true", val[0])
|
||||||
|
|
||||||
|
val, found = messageFlag.Annotations[cobra.BashCompOneRequiredFlag]
|
||||||
|
require.True(t, found)
|
||||||
|
require.NotEmpty(t, val)
|
||||||
|
assert.Equal(t, "true", val[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAddCommandEveryAndCronMutuallyExclusive(t *testing.T) {
|
||||||
|
cmd := newAddCommand(func() string { return "testing" })
|
||||||
|
|
||||||
|
cmd.SetArgs([]string{
|
||||||
|
"--name", "job",
|
||||||
|
"--message", "hello",
|
||||||
|
"--every", "10",
|
||||||
|
"--cron", "0 9 * * *",
|
||||||
|
})
|
||||||
|
|
||||||
|
err := cmd.Execute()
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewCronCommand() *cobra.Command {
|
||||||
|
var storePath string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "cron",
|
||||||
|
Aliases: []string{"c"},
|
||||||
|
Short: "Manage scheduled tasks",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
|
// Resolve storePath at execution time so it reflects the current config
|
||||||
|
// and is shared across all subcommands.
|
||||||
|
PersistentPreRunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := internal.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
|
}
|
||||||
|
storePath = filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
newListCommand(func() string { return storePath }),
|
||||||
|
newAddCommand(func() string { return storePath }),
|
||||||
|
newRemoveCommand(func() string { return storePath }),
|
||||||
|
newEnableCommand(func() string { return storePath }),
|
||||||
|
newDisableCommand(func() string { return storePath }),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewCronCommand(t *testing.T) {
|
||||||
|
cmd := NewCronCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "Manage scheduled tasks", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 1)
|
||||||
|
assert.True(t, cmd.HasAlias("c"))
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.PersistentPreRunE)
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
allowedCommands := []string{
|
||||||
|
"list",
|
||||||
|
"add",
|
||||||
|
"remove",
|
||||||
|
"enable",
|
||||||
|
"disable",
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommands := cmd.Commands()
|
||||||
|
assert.Len(t, subcommands, len(allowedCommands))
|
||||||
|
|
||||||
|
for _, subcmd := range subcommands {
|
||||||
|
found := slices.Contains(allowedCommands, subcmd.Name())
|
||||||
|
assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
|
||||||
|
|
||||||
|
assert.Len(t, subcmd.Aliases, 0)
|
||||||
|
assert.False(t, subcmd.Hidden)
|
||||||
|
|
||||||
|
assert.False(t, subcmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.Nil(t, subcmd.Run)
|
||||||
|
assert.NotNil(t, subcmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, subcmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, subcmd.PersistentPostRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newDisableCommand(storePath func() string) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "disable",
|
||||||
|
Short: "Disable a job",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Example: `picoclaw cron disable 1`,
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
cronSetJobEnabled(storePath(), args[0], false)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDisableSubcommand(t *testing.T) {
|
||||||
|
fn := func() string { return "" }
|
||||||
|
cmd := newDisableCommand(fn)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "disable", cmd.Use)
|
||||||
|
assert.Equal(t, "Disable a job", cmd.Short)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newEnableCommand(storePath func() string) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "enable",
|
||||||
|
Short: "Enable a job",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Example: `picoclaw cron enable 1`,
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
cronSetJobEnabled(storePath(), args[0], true)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnableSubcommand(t *testing.T) {
|
||||||
|
fn := func() string { return "" }
|
||||||
|
cmd := newEnableCommand(fn)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "enable", cmd.Use)
|
||||||
|
assert.Equal(t, "Enable a job", cmd.Short)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cronListCmd(storePath string) {
|
||||||
|
cs := cron.NewCronService(storePath, nil)
|
||||||
|
jobs := cs.ListJobs(true) // Show all jobs, including disabled
|
||||||
|
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
fmt.Println("No scheduled jobs.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("\nScheduled Jobs:")
|
||||||
|
fmt.Println("----------------")
|
||||||
|
for _, job := range jobs {
|
||||||
|
var schedule string
|
||||||
|
if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
|
||||||
|
schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
|
||||||
|
} else if job.Schedule.Kind == "cron" {
|
||||||
|
schedule = job.Schedule.Expr
|
||||||
|
} else {
|
||||||
|
schedule = "one-time"
|
||||||
|
}
|
||||||
|
|
||||||
|
nextRun := "scheduled"
|
||||||
|
if job.State.NextRunAtMS != nil {
|
||||||
|
nextTime := time.UnixMilli(*job.State.NextRunAtMS)
|
||||||
|
nextRun = nextTime.Format("2006-01-02 15:04")
|
||||||
|
}
|
||||||
|
|
||||||
|
status := "enabled"
|
||||||
|
if !job.Enabled {
|
||||||
|
status = "disabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf(" %s (%s)\n", job.Name, job.ID)
|
||||||
|
fmt.Printf(" Schedule: %s\n", schedule)
|
||||||
|
fmt.Printf(" Status: %s\n", status)
|
||||||
|
fmt.Printf(" Next run: %s\n", nextRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronRemoveCmd(storePath, jobID string) {
|
||||||
|
cs := cron.NewCronService(storePath, nil)
|
||||||
|
if cs.RemoveJob(jobID) {
|
||||||
|
fmt.Printf("✓ Removed job %s\n", jobID)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cronSetJobEnabled(storePath, jobID string, enabled bool) {
|
||||||
|
cs := cron.NewCronService(storePath, nil)
|
||||||
|
job := cs.EnableJob(jobID, enabled)
|
||||||
|
if job != nil {
|
||||||
|
fmt.Printf("✓ Job '%s' enabled\n", job.Name)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("✗ Job %s not found\n", jobID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newListCommand(storePath func() string) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all scheduled jobs",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
cronListCmd(storePath())
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewListSubcommand(t *testing.T) {
|
||||||
|
fn := func() string { return "" }
|
||||||
|
cmd := newListCommand(fn)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "List all scheduled jobs", cmd.Short)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newRemoveCommand(storePath func() string) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remove",
|
||||||
|
Short: "Remove a job by ID",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Example: `picoclaw cron remove 1`,
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
cronRemoveCmd(storePath(), args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package cron
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRemoveSubcommand(t *testing.T) {
|
||||||
|
fn := func() string { return "" }
|
||||||
|
cmd := newRemoveCommand(fn)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "Remove a job by ID", cmd.Short)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewGatewayCommand() *cobra.Command {
|
||||||
|
var debug bool
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "gateway",
|
||||||
|
Aliases: []string{"g"},
|
||||||
|
Short: "Start picoclaw gateway",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
return gatewayCmd(debug)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package gateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewGatewayCommand(t *testing.T) {
|
||||||
|
cmd := NewGatewayCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "gateway", cmd.Use)
|
||||||
|
assert.Equal(t, "Start picoclaw gateway", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 1)
|
||||||
|
assert.True(t, cmd.HasAlias("g"))
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasFlags())
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("debug"))
|
||||||
|
}
|
||||||
@@ -1,58 +1,61 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package gateway
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/agent"
|
"github.com/sipeed/picoclaw/pkg/agent"
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
"github.com/sipeed/picoclaw/pkg/channels"
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/line"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
|
||||||
|
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/cron"
|
"github.com/sipeed/picoclaw/pkg/cron"
|
||||||
"github.com/sipeed/picoclaw/pkg/devices"
|
"github.com/sipeed/picoclaw/pkg/devices"
|
||||||
"github.com/sipeed/picoclaw/pkg/health"
|
"github.com/sipeed/picoclaw/pkg/health"
|
||||||
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
"github.com/sipeed/picoclaw/pkg/heartbeat"
|
||||||
"github.com/sipeed/picoclaw/pkg/logger"
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/media"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/state"
|
"github.com/sipeed/picoclaw/pkg/state"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
"github.com/sipeed/picoclaw/pkg/voice"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func gatewayCmd() {
|
func gatewayCmd(debug bool) error {
|
||||||
// Check for --debug flag
|
if debug {
|
||||||
args := os.Args[2:]
|
|
||||||
for _, arg := range args {
|
|
||||||
if arg == "--debug" || arg == "-d" {
|
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
fmt.Println("🔍 Debug mode enabled")
|
fmt.Println("🔍 Debug mode enabled")
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
provider, modelID, err := providers.CreateProvider(cfg)
|
provider, modelID, err := providers.CreateProvider(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating provider: %v\n", err)
|
return fmt.Errorf("error creating provider: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the resolved model ID from provider creation
|
// Use the resolved model ID from provider creation
|
||||||
if modelID != "" {
|
if modelID != "" {
|
||||||
cfg.Agents.Defaults.Model = modelID
|
cfg.Agents.Defaults.ModelName = modelID
|
||||||
}
|
}
|
||||||
|
|
||||||
msgBus := bus.NewMessageBus()
|
msgBus := bus.NewMessageBus()
|
||||||
@@ -112,50 +115,23 @@ func gatewayCmd() {
|
|||||||
return tools.SilentResult(response)
|
return tools.SilentResult(response)
|
||||||
})
|
})
|
||||||
|
|
||||||
channelManager, err := channels.NewManager(cfg, msgBus)
|
// Create media store for file lifecycle management with TTL cleanup
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
mediaStore.Start()
|
||||||
|
|
||||||
|
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error creating channel manager: %v\n", err)
|
mediaStore.Stop()
|
||||||
os.Exit(1)
|
return fmt.Errorf("error creating channel manager: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inject channel manager into agent loop for command handling
|
// Inject channel manager and media store into agent loop
|
||||||
agentLoop.SetChannelManager(channelManager)
|
agentLoop.SetChannelManager(channelManager)
|
||||||
|
agentLoop.SetMediaStore(mediaStore)
|
||||||
var transcriber *voice.GroqTranscriber
|
|
||||||
groqAPIKey := cfg.Providers.Groq.APIKey
|
|
||||||
if groqAPIKey == "" {
|
|
||||||
for _, mc := range cfg.ModelList {
|
|
||||||
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
|
|
||||||
groqAPIKey = mc.APIKey
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if groqAPIKey != "" {
|
|
||||||
transcriber = voice.NewGroqTranscriber(groqAPIKey)
|
|
||||||
logger.InfoC("voice", "Groq voice transcription enabled")
|
|
||||||
}
|
|
||||||
|
|
||||||
if transcriber != nil {
|
|
||||||
if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
|
|
||||||
if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
|
|
||||||
tc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Telegram channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if discordChannel, ok := channelManager.GetChannel("discord"); ok {
|
|
||||||
if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
|
|
||||||
dc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Discord channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if slackChannel, ok := channelManager.GetChannel("slack"); ok {
|
|
||||||
if sc, ok := slackChannel.(*channels.SlackChannel); ok {
|
|
||||||
sc.SetTranscriber(transcriber)
|
|
||||||
logger.InfoC("voice", "Groq transcription attached to Slack channel")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enabledChannels := channelManager.GetEnabledChannels()
|
enabledChannels := channelManager.GetEnabledChannels()
|
||||||
if len(enabledChannels) > 0 {
|
if len(enabledChannels) > 0 {
|
||||||
@@ -192,16 +168,15 @@ func gatewayCmd() {
|
|||||||
fmt.Println("✓ Device event service started")
|
fmt.Println("✓ Device event service started")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
channelManager.SetupHTTPServer(addr, healthServer)
|
||||||
|
|
||||||
if err := channelManager.StartAll(ctx); err != nil {
|
if err := channelManager.StartAll(ctx); err != nil {
|
||||||
fmt.Printf("Error starting channels: %v\n", err)
|
fmt.Printf("Error starting channels: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
|
|
||||||
go func() {
|
|
||||||
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
|
|
||||||
logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
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)
|
go agentLoop.Run(ctx)
|
||||||
@@ -211,14 +186,26 @@ func gatewayCmd() {
|
|||||||
<-sigChan
|
<-sigChan
|
||||||
|
|
||||||
fmt.Println("\nShutting down...")
|
fmt.Println("\nShutting down...")
|
||||||
|
if cp, ok := provider.(providers.StatefulProvider); ok {
|
||||||
|
cp.Close()
|
||||||
|
}
|
||||||
cancel()
|
cancel()
|
||||||
healthServer.Stop(context.Background())
|
msgBus.Close()
|
||||||
|
|
||||||
|
// Use a fresh context with timeout for graceful shutdown,
|
||||||
|
// since the original ctx is already canceled.
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
|
||||||
|
channelManager.StopAll(shutdownCtx)
|
||||||
deviceService.Stop()
|
deviceService.Stop()
|
||||||
heartbeatService.Stop()
|
heartbeatService.Stop()
|
||||||
cronService.Stop()
|
cronService.Stop()
|
||||||
|
mediaStore.Stop()
|
||||||
agentLoop.Stop()
|
agentLoop.Stop()
|
||||||
channelManager.StopAll(ctx)
|
|
||||||
fmt.Println("✓ Gateway stopped")
|
fmt.Println("✓ Gateway stopped")
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setupCronTool(
|
func setupCronTool(
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Logo = "🦞"
|
||||||
|
|
||||||
|
var (
|
||||||
|
version = "dev"
|
||||||
|
gitCommit string
|
||||||
|
buildTime string
|
||||||
|
goVersion string
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetConfigPath() string {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
return filepath.Join(home, ".picoclaw", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadConfig() (*config.Config, error) {
|
||||||
|
return config.LoadConfig(GetConfigPath())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package internal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetConfigPath(t *testing.T) {
|
||||||
|
t.Setenv("HOME", "/tmp/home")
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := filepath.Join("/tmp/home", ".picoclaw", "config.json")
|
||||||
|
|
||||||
|
assert.Equal(t, want, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersion_NoGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := version, gitCommit
|
||||||
|
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
version = "1.2.3"
|
||||||
|
gitCommit = ""
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatVersion_WithGitCommit(t *testing.T) {
|
||||||
|
oldVersion, oldGit := version, gitCommit
|
||||||
|
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
|
||||||
|
|
||||||
|
version = "1.2.3"
|
||||||
|
gitCommit = "abc123"
|
||||||
|
|
||||||
|
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||||
|
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
buildTime = "2026-02-20T00:00:00Z"
|
||||||
|
goVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, buildTime, build)
|
||||||
|
assert.Equal(t, goVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||||
|
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
buildTime = ""
|
||||||
|
goVersion = "go1.23.0"
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Empty(t, build)
|
||||||
|
assert.Equal(t, goVersion, goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
|
||||||
|
oldBuildTime, oldGoVersion := buildTime, goVersion
|
||||||
|
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
|
||||||
|
|
||||||
|
buildTime = "x"
|
||||||
|
goVersion = ""
|
||||||
|
|
||||||
|
build, goVer := FormatBuildInfo()
|
||||||
|
|
||||||
|
assert.Equal(t, "x", build)
|
||||||
|
assert.Equal(t, runtime.Version(), goVer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConfigPath_Windows(t *testing.T) {
|
||||||
|
if runtime.GOOS != "windows" {
|
||||||
|
t.Skip("windows-specific HOME behavior varies; run on windows")
|
||||||
|
}
|
||||||
|
|
||||||
|
testUserProfilePath := `C:\Users\Test`
|
||||||
|
t.Setenv("USERPROFILE", testUserProfilePath)
|
||||||
|
|
||||||
|
got := GetConfigPath()
|
||||||
|
want := filepath.Join(testUserProfilePath, ".picoclaw", "config.json")
|
||||||
|
|
||||||
|
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetVersion(t *testing.T) {
|
||||||
|
assert.Equal(t, "dev", GetVersion())
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/migrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewMigrateCommand() *cobra.Command {
|
||||||
|
var opts migrate.Options
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "migrate",
|
||||||
|
Short: "Migrate from OpenClaw to PicoClaw",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
Example: ` picoclaw migrate
|
||||||
|
picoclaw migrate --dry-run
|
||||||
|
picoclaw migrate --refresh
|
||||||
|
picoclaw migrate --force`,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
result, err := migrate.Run(opts)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !opts.DryRun {
|
||||||
|
migrate.PrintSummary(result)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false,
|
||||||
|
"Show what would be migrated without making changes")
|
||||||
|
cmd.Flags().BoolVar(&opts.Refresh, "refresh", false,
|
||||||
|
"Re-sync workspace files from OpenClaw (repeatable)")
|
||||||
|
cmd.Flags().BoolVar(&opts.ConfigOnly, "config-only", false,
|
||||||
|
"Only migrate config, skip workspace files")
|
||||||
|
cmd.Flags().BoolVar(&opts.WorkspaceOnly, "workspace-only", false,
|
||||||
|
"Only migrate workspace files, skip config")
|
||||||
|
cmd.Flags().BoolVar(&opts.Force, "force", false,
|
||||||
|
"Skip confirmation prompts")
|
||||||
|
cmd.Flags().StringVar(&opts.OpenClawHome, "openclaw-home", "",
|
||||||
|
"Override OpenClaw home directory (default: ~/.openclaw)")
|
||||||
|
cmd.Flags().StringVar(&opts.PicoClawHome, "picoclaw-home", "",
|
||||||
|
"Override PicoClaw home directory (default: ~/.picoclaw)")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package migrate
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewMigrateCommand(t *testing.T) {
|
||||||
|
cmd := NewMigrateCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "migrate", cmd.Use)
|
||||||
|
assert.Equal(t, "Migrate from OpenClaw to PicoClaw", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("dry-run"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("refresh"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("config-only"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("workspace-only"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("force"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("openclaw-home"))
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("picoclaw-home"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package onboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:generate cp -r ../../../../workspace .
|
||||||
|
//go:embed workspace
|
||||||
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
|
func NewOnboardCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "onboard",
|
||||||
|
Aliases: []string{"o"},
|
||||||
|
Short: "Initialize picoclaw configuration and workspace",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
onboard()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package onboard
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewOnboardCommand(t *testing.T) {
|
||||||
|
cmd := NewOnboardCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "onboard", cmd.Use)
|
||||||
|
assert.Equal(t, "Initialize picoclaw configuration and workspace", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 1)
|
||||||
|
assert.True(t, cmd.HasAlias("o"))
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Run)
|
||||||
|
assert.Nil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
}
|
||||||
@@ -1,24 +1,17 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package onboard
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:generate cp -r ../../workspace .
|
|
||||||
//go:embed workspace
|
|
||||||
var embeddedFiles embed.FS
|
|
||||||
|
|
||||||
func onboard() {
|
func onboard() {
|
||||||
configPath := getConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
fmt.Printf("Config already exists at %s\n", configPath)
|
fmt.Printf("Config already exists at %s\n", configPath)
|
||||||
@@ -40,7 +33,7 @@ func onboard() {
|
|||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
createWorkspaceTemplates(workspace)
|
createWorkspaceTemplates(workspace)
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw is ready!\n", logo)
|
fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
|
||||||
fmt.Println("\nNext steps:")
|
fmt.Println("\nNext steps:")
|
||||||
fmt.Println(" 1. Add your API key to", configPath)
|
fmt.Println(" 1. Add your API key to", configPath)
|
||||||
fmt.Println("")
|
fmt.Println("")
|
||||||
@@ -53,6 +46,13 @@ func onboard() {
|
|||||||
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func createWorkspaceTemplates(workspace string) {
|
||||||
|
err := copyEmbeddedToTarget(workspace)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error copying workspace templates: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func copyEmbeddedToTarget(targetDir string) error {
|
func copyEmbeddedToTarget(targetDir string) error {
|
||||||
// Ensure target directory exists
|
// Ensure target directory exists
|
||||||
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
if err := os.MkdirAll(targetDir, 0o755); err != nil {
|
||||||
@@ -99,10 +99,3 @@ func copyEmbeddedToTarget(targetDir string) error {
|
|||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func createWorkspaceTemplates(workspace string) {
|
|
||||||
err := copyEmbeddedToTarget(workspace)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error copying workspace templates: %v\n", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deps struct {
|
||||||
|
workspace string
|
||||||
|
installer *skills.SkillInstaller
|
||||||
|
skillsLoader *skills.SkillsLoader
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSkillsCommand() *cobra.Command {
|
||||||
|
var d deps
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "skills",
|
||||||
|
Short: "Manage skills",
|
||||||
|
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
cfg, err := internal.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error loading config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.workspace = cfg.WorkspacePath()
|
||||||
|
d.installer = skills.NewSkillInstaller(d.workspace)
|
||||||
|
|
||||||
|
// get global config directory and builtin skills directory
|
||||||
|
globalDir := filepath.Dir(internal.GetConfigPath())
|
||||||
|
globalSkillsDir := filepath.Join(globalDir, "skills")
|
||||||
|
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
||||||
|
d.skillsLoader = skills.NewSkillsLoader(d.workspace, globalSkillsDir, builtinSkillsDir)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return cmd.Help()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
installerFn := func() (*skills.SkillInstaller, error) {
|
||||||
|
if d.installer == nil {
|
||||||
|
return nil, fmt.Errorf("skills installer is not initialized")
|
||||||
|
}
|
||||||
|
return d.installer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
loaderFn := func() (*skills.SkillsLoader, error) {
|
||||||
|
if d.skillsLoader == nil {
|
||||||
|
return nil, fmt.Errorf("skills loader is not initialized")
|
||||||
|
}
|
||||||
|
return d.skillsLoader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceFn := func() (string, error) {
|
||||||
|
if d.workspace == "" {
|
||||||
|
return "", fmt.Errorf("workspace is not initialized")
|
||||||
|
}
|
||||||
|
return d.workspace, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
newListCommand(loaderFn),
|
||||||
|
newInstallCommand(installerFn),
|
||||||
|
newInstallBuiltinCommand(workspaceFn),
|
||||||
|
newListBuiltinCommand(),
|
||||||
|
newRemoveCommand(installerFn),
|
||||||
|
newSearchCommand(installerFn),
|
||||||
|
newShowCommand(loaderFn),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSkillsCommand(t *testing.T) {
|
||||||
|
cmd := NewSkillsCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "skills", cmd.Use)
|
||||||
|
assert.Equal(t, "Manage skills", cmd.Short)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.PersistentPreRunE)
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
}
|
||||||
@@ -1,40 +1,20 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package skills
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
"github.com/sipeed/picoclaw/pkg/utils"
|
"github.com/sipeed/picoclaw/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func skillsHelp() {
|
|
||||||
fmt.Println("\nSkills commands:")
|
|
||||||
fmt.Println(" list List installed skills")
|
|
||||||
fmt.Println(" install <repo> Install skill from GitHub")
|
|
||||||
fmt.Println(" install-builtin Install all builtin skills to workspace")
|
|
||||||
fmt.Println(" list-builtin List available builtin skills")
|
|
||||||
fmt.Println(" remove <name> Remove installed skill")
|
|
||||||
fmt.Println(" search Search available skills")
|
|
||||||
fmt.Println(" show <name> Show skill details")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Examples:")
|
|
||||||
fmt.Println(" picoclaw skills list")
|
|
||||||
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
|
|
||||||
fmt.Println(" picoclaw skills install-builtin")
|
|
||||||
fmt.Println(" picoclaw skills list-builtin")
|
|
||||||
fmt.Println(" picoclaw skills remove weather")
|
|
||||||
fmt.Println(" picoclaw skills install --registry clawhub github")
|
|
||||||
}
|
|
||||||
|
|
||||||
func skillsListCmd(loader *skills.SkillsLoader) {
|
func skillsListCmd(loader *skills.SkillsLoader) {
|
||||||
allSkills := loader.ListSkills()
|
allSkills := loader.ListSkills()
|
||||||
|
|
||||||
@@ -53,53 +33,31 @@ func skillsListCmd(loader *skills.SkillsLoader) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
|
func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error {
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw skills install <github-repo>")
|
|
||||||
fmt.Println(" picoclaw skills install --registry <name> <slug>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for --registry flag.
|
|
||||||
if os.Args[3] == "--registry" {
|
|
||||||
if len(os.Args) < 6 {
|
|
||||||
fmt.Println("Usage: picoclaw skills install --registry <name> <slug>")
|
|
||||||
fmt.Println("Example: picoclaw skills install --registry clawhub github")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
registryName := os.Args[4]
|
|
||||||
slug := os.Args[5]
|
|
||||||
skillsInstallFromRegistry(cfg, registryName, slug)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: install from GitHub (backward compatible).
|
|
||||||
repo := os.Args[3]
|
|
||||||
fmt.Printf("Installing skill from %s...\n", repo)
|
fmt.Printf("Installing skill from %s...\n", repo)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
if err := installer.InstallFromGitHub(ctx, repo); err != nil {
|
||||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
return fmt.Errorf("failed to install skill: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
|
||||||
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) error {
|
||||||
err := utils.ValidateSkillIdentifier(registryName)
|
err := utils.ValidateSkillIdentifier(registryName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("\u2717 Invalid registry name: %v\n", err)
|
return fmt.Errorf("✗ invalid registry name: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = utils.ValidateSkillIdentifier(slug)
|
err = utils.ValidateSkillIdentifier(slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("\u2717 Invalid slug: %v\n", err)
|
return fmt.Errorf("✗ invalid slug: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
|
||||||
@@ -111,24 +69,21 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||||||
|
|
||||||
registry := registryMgr.GetRegistry(registryName)
|
registry := registryMgr.GetRegistry(registryName)
|
||||||
if registry == nil {
|
if registry == nil {
|
||||||
fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
|
return fmt.Errorf("✗ registry '%s' not found or not enabled. check your config.json.", registryName)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
workspace := cfg.WorkspacePath()
|
||||||
targetDir := filepath.Join(workspace, "skills", slug)
|
targetDir := filepath.Join(workspace, "skills", slug)
|
||||||
|
|
||||||
if _, err = os.Stat(targetDir); err == nil {
|
if _, err = os.Stat(targetDir); err == nil {
|
||||||
fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
|
return fmt.Errorf("\u2717 skill '%s' already installed at %s", slug, targetDir)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
|
||||||
fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
|
return fmt.Errorf("\u2717 failed to create skills directory: %v", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
|
||||||
@@ -137,8 +92,7 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||||||
if rmErr != nil {
|
if rmErr != nil {
|
||||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||||
}
|
}
|
||||||
fmt.Printf("\u2717 Failed to install skill: %v\n", err)
|
return fmt.Errorf("✗ failed to install skill: %w", err)
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.IsMalwareBlocked {
|
if result.IsMalwareBlocked {
|
||||||
@@ -146,8 +100,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||||||
if rmErr != nil {
|
if rmErr != nil {
|
||||||
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
|
||||||
}
|
}
|
||||||
fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
|
||||||
os.Exit(1)
|
return fmt.Errorf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.IsSuspicious {
|
if result.IsSuspicious {
|
||||||
@@ -158,6 +112,8 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
|
|||||||
if result.Summary != "" {
|
if result.Summary != "" {
|
||||||
fmt.Printf(" %s\n", result.Summary)
|
fmt.Printf(" %s\n", result.Summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
|
||||||
@@ -208,7 +164,7 @@ func skillsInstallBuiltinCmd(workspace string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func skillsListBuiltinCmd() {
|
func skillsListBuiltinCmd() {
|
||||||
cfg, err := loadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
return
|
return
|
||||||
@@ -303,3 +259,37 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
|
|||||||
fmt.Println("----------------------")
|
fmt.Println("----------------------")
|
||||||
fmt.Println(content)
|
fmt.Println(content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func copyDirectory(src, dst string) error {
|
||||||
|
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
relPath, err := filepath.Rel(src, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dstPath := filepath.Join(dst, relPath)
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return os.MkdirAll(dstPath, info.Mode())
|
||||||
|
}
|
||||||
|
|
||||||
|
srcFile, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer srcFile.Close()
|
||||||
|
|
||||||
|
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer dstFile.Close()
|
||||||
|
|
||||||
|
_, err = io.Copy(dstFile, srcFile)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newInstallCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||||
|
var registry string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "install",
|
||||||
|
Short: "Install skill from GitHub",
|
||||||
|
Example: `
|
||||||
|
picoclaw skills install sipeed/picoclaw-skills/weather
|
||||||
|
picoclaw skills install --registry clawhub github
|
||||||
|
`,
|
||||||
|
Args: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if registry != "" {
|
||||||
|
if len(args) != 2 {
|
||||||
|
return fmt.Errorf("when --registry is set, exactly 2 arguments are required: <name> <slug>")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) != 1 {
|
||||||
|
return fmt.Errorf("exactly 1 argument is required: <github>")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
installer, err := installerFn()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if registry != "" {
|
||||||
|
cfg, err := internal.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return skillsInstallFromRegistry(cfg, args[0], args[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return skillsInstallCmd(installer, args[0])
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(®istry, "registry", "", "Install from registry: --registry <name> <slug>")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewInstallSubcommand(t *testing.T) {
|
||||||
|
cmd := newInstallCommand(nil)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "install", cmd.Use)
|
||||||
|
assert.Equal(t, "Install skill from GitHub", cmd.Short)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasFlags())
|
||||||
|
assert.NotNil(t, cmd.Flags().Lookup("registry"))
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newInstallBuiltinCommand(workspaceFn func() (string, error)) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "install-builtin",
|
||||||
|
Short: "Install all builtin skills to workspace",
|
||||||
|
Example: `picoclaw skills install-builtin`,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
workspace, err := workspaceFn()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
skillsInstallBuiltinCmd(workspace)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewInstallbuiltinSubcommand(t *testing.T) {
|
||||||
|
cmd := newInstallBuiltinCommand(nil)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "install-builtin", cmd.Use)
|
||||||
|
assert.Equal(t, "Install all builtin skills to workspace", cmd.Short)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newListCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List installed skills",
|
||||||
|
Example: `picoclaw skills list`,
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
loader, err := loaderFn()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
skillsListCmd(loader)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewListSubcommand(t *testing.T) {
|
||||||
|
cmd := newListCommand(nil)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "list", cmd.Use)
|
||||||
|
assert.Equal(t, "List installed skills", cmd.Short)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import "github.com/spf13/cobra"
|
||||||
|
|
||||||
|
func newListBuiltinCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "list-builtin",
|
||||||
|
Short: "List available builtin skills",
|
||||||
|
Example: `picoclaw skills list-builtin`,
|
||||||
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
|
skillsListBuiltinCmd()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewListbuiltinSubcommand(t *testing.T) {
|
||||||
|
cmd := newListBuiltinCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "list-builtin", cmd.Use)
|
||||||
|
assert.Equal(t, "List available builtin skills", cmd.Short)
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Run)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newRemoveCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "remove",
|
||||||
|
Aliases: []string{"rm", "uninstall"},
|
||||||
|
Short: "Remove installed skill",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Example: `picoclaw skills remove weather`,
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
installer, err := installerFn()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
skillsRemoveCmd(installer, args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewRemoveSubcommand(t *testing.T) {
|
||||||
|
cmd := newRemoveCommand(nil)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "remove", cmd.Use)
|
||||||
|
assert.Equal(t, "Remove installed skill", cmd.Short)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 2)
|
||||||
|
assert.True(t, cmd.HasAlias("rm"))
|
||||||
|
assert.True(t, cmd.HasAlias("uninstall"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newSearchCommand(installerFn func() (*skills.SkillInstaller, error)) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "search",
|
||||||
|
Short: "Search available skills",
|
||||||
|
RunE: func(_ *cobra.Command, _ []string) error {
|
||||||
|
installer, err := installerFn()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
skillsSearchCmd(installer)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSearchSubcommand(t *testing.T) {
|
||||||
|
cmd := newSearchCommand(nil)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "search", cmd.Use)
|
||||||
|
assert.Equal(t, "Search available skills", cmd.Short)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newShowCommand(loaderFn func() (*skills.SkillsLoader, error)) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "show",
|
||||||
|
Short: "Show skill details",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Example: `picoclaw skills show weather`,
|
||||||
|
RunE: func(_ *cobra.Command, args []string) error {
|
||||||
|
loader, err := loaderFn()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
skillsShowCmd(loader, args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package skills
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewShowSubcommand(t *testing.T) {
|
||||||
|
cmd := newShowCommand(nil)
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "show", cmd.Use)
|
||||||
|
assert.Equal(t, "Show skill details", cmd.Short)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.NotNil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasExample())
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewStatusCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "status",
|
||||||
|
Aliases: []string{"s"},
|
||||||
|
Short: "Show picoclaw status",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
statusCmd()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package status
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewStatusCommand(t *testing.T) {
|
||||||
|
cmd := NewStatusCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "status", cmd.Use)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 1)
|
||||||
|
assert.True(t, cmd.HasAlias("s"))
|
||||||
|
|
||||||
|
assert.Equal(t, "Show picoclaw status", cmd.Short)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Run)
|
||||||
|
assert.Nil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
}
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
// PicoClaw - Ultra-lightweight personal AI agent
|
package status
|
||||||
// License: MIT
|
|
||||||
|
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
"github.com/sipeed/picoclaw/pkg/auth"
|
"github.com/sipeed/picoclaw/pkg/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func statusCmd() {
|
func statusCmd() {
|
||||||
cfg, err := loadConfig()
|
cfg, err := internal.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
fmt.Printf("Error loading config: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
configPath := getConfigPath()
|
configPath := internal.GetConfigPath()
|
||||||
|
|
||||||
fmt.Printf("%s picoclaw Status\n", logo)
|
fmt.Printf("%s picoclaw Status\n", internal.Logo)
|
||||||
fmt.Printf("Version: %s\n", formatVersion())
|
fmt.Printf("Version: %s\n", internal.FormatVersion())
|
||||||
build, _ := formatBuildInfo()
|
build, _ := internal.FormatBuildInfo()
|
||||||
if build != "" {
|
if build != "" {
|
||||||
fmt.Printf("Build: %s\n", build)
|
fmt.Printf("Build: %s\n", build)
|
||||||
}
|
}
|
||||||
@@ -41,7 +39,7 @@ func statusCmd() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := os.Stat(configPath); err == nil {
|
if _, err := os.Stat(configPath); err == nil {
|
||||||
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
|
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
|
||||||
|
|
||||||
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
|
||||||
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewVersionCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Aliases: []string{"v"},
|
||||||
|
Short: "Show version information",
|
||||||
|
Run: func(_ *cobra.Command, _ []string) {
|
||||||
|
printVersion()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func printVersion() {
|
||||||
|
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
|
||||||
|
build, goVer := internal.FormatBuildInfo()
|
||||||
|
if build != "" {
|
||||||
|
fmt.Printf(" Build: %s\n", build)
|
||||||
|
}
|
||||||
|
if goVer != "" {
|
||||||
|
fmt.Printf(" Go: %s\n", goVer)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewVersionCommand(t *testing.T) {
|
||||||
|
cmd := NewVersionCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
assert.Equal(t, "version", cmd.Use)
|
||||||
|
|
||||||
|
assert.Len(t, cmd.Aliases, 1)
|
||||||
|
assert.True(t, cmd.HasAlias("v"))
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Equal(t, "Show version information", cmd.Short)
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasSubCommands())
|
||||||
|
|
||||||
|
assert.NotNil(t, cmd.Run)
|
||||||
|
assert.Nil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
}
|
||||||
+32
-175
@@ -8,192 +8,49 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/spf13/cobra"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
func NewPicoclawCommand() *cobra.Command {
|
||||||
version = "dev"
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
||||||
gitCommit string
|
|
||||||
buildTime string
|
|
||||||
goVersion string
|
|
||||||
)
|
|
||||||
|
|
||||||
const logo = "🦞"
|
cmd := &cobra.Command{
|
||||||
|
Use: "picoclaw",
|
||||||
// formatVersion returns the version string with optional git commit
|
Short: short,
|
||||||
func formatVersion() string {
|
Example: "picoclaw list",
|
||||||
v := version
|
|
||||||
if gitCommit != "" {
|
|
||||||
v += fmt.Sprintf(" (git: %s)", gitCommit)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatBuildInfo returns build time and go version info
|
|
||||||
func formatBuildInfo() (build string, goVer string) {
|
|
||||||
if buildTime != "" {
|
|
||||||
build = buildTime
|
|
||||||
}
|
|
||||||
goVer = goVersion
|
|
||||||
if goVer == "" {
|
|
||||||
goVer = runtime.Version()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func printVersion() {
|
|
||||||
fmt.Printf("%s picoclaw %s\n", logo, formatVersion())
|
|
||||||
build, goVer := formatBuildInfo()
|
|
||||||
if build != "" {
|
|
||||||
fmt.Printf(" Build: %s\n", build)
|
|
||||||
}
|
|
||||||
if goVer != "" {
|
|
||||||
fmt.Printf(" Go: %s\n", goVer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func copyDirectory(src, dst string) error {
|
|
||||||
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
relPath, err := filepath.Rel(src, path)
|
cmd.AddCommand(
|
||||||
if err != nil {
|
onboard.NewOnboardCommand(),
|
||||||
return err
|
agent.NewAgentCommand(),
|
||||||
}
|
auth.NewAuthCommand(),
|
||||||
|
gateway.NewGatewayCommand(),
|
||||||
|
status.NewStatusCommand(),
|
||||||
|
cron.NewCronCommand(),
|
||||||
|
migrate.NewMigrateCommand(),
|
||||||
|
skills.NewSkillsCommand(),
|
||||||
|
version.NewVersionCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
dstPath := filepath.Join(dst, relPath)
|
return cmd
|
||||||
|
|
||||||
if info.IsDir() {
|
|
||||||
return os.MkdirAll(dstPath, info.Mode())
|
|
||||||
}
|
|
||||||
|
|
||||||
srcFile, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer srcFile.Close()
|
|
||||||
|
|
||||||
dstFile, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode())
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer dstFile.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(dstFile, srcFile)
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
cmd := NewPicoclawCommand()
|
||||||
printHelp()
|
if err := cmd.Execute(); err != nil {
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
command := os.Args[1]
|
|
||||||
|
|
||||||
switch command {
|
|
||||||
case "onboard":
|
|
||||||
onboard()
|
|
||||||
case "agent":
|
|
||||||
agentCmd()
|
|
||||||
case "gateway":
|
|
||||||
gatewayCmd()
|
|
||||||
case "status":
|
|
||||||
statusCmd()
|
|
||||||
case "migrate":
|
|
||||||
migrateCmd()
|
|
||||||
case "auth":
|
|
||||||
authCmd()
|
|
||||||
case "cron":
|
|
||||||
cronCmd()
|
|
||||||
case "skills":
|
|
||||||
if len(os.Args) < 3 {
|
|
||||||
skillsHelp()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
subcommand := os.Args[2]
|
|
||||||
|
|
||||||
cfg, err := loadConfig()
|
|
||||||
if err != nil {
|
|
||||||
fmt.Printf("Error loading config: %v\n", err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
workspace := cfg.WorkspacePath()
|
|
||||||
installer := skills.NewSkillInstaller(workspace)
|
|
||||||
// 获取全局配置目录和内置 skills 目录
|
|
||||||
globalDir := filepath.Dir(getConfigPath())
|
|
||||||
globalSkillsDir := filepath.Join(globalDir, "skills")
|
|
||||||
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
|
|
||||||
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
|
|
||||||
|
|
||||||
switch subcommand {
|
|
||||||
case "list":
|
|
||||||
skillsListCmd(skillsLoader)
|
|
||||||
case "install":
|
|
||||||
skillsInstallCmd(installer, cfg)
|
|
||||||
case "remove", "uninstall":
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw skills remove <skill-name>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
skillsRemoveCmd(installer, os.Args[3])
|
|
||||||
case "install-builtin":
|
|
||||||
skillsInstallBuiltinCmd(workspace)
|
|
||||||
case "list-builtin":
|
|
||||||
skillsListBuiltinCmd()
|
|
||||||
case "search":
|
|
||||||
skillsSearchCmd(installer)
|
|
||||||
case "show":
|
|
||||||
if len(os.Args) < 4 {
|
|
||||||
fmt.Println("Usage: picoclaw skills show <skill-name>")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
skillsShowCmd(skillsLoader, os.Args[3])
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown skills command: %s\n", subcommand)
|
|
||||||
skillsHelp()
|
|
||||||
}
|
|
||||||
case "version", "--version", "-v":
|
|
||||||
printVersion()
|
|
||||||
default:
|
|
||||||
fmt.Printf("Unknown command: %s\n", command)
|
|
||||||
printHelp()
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func printHelp() {
|
|
||||||
fmt.Printf("%s picoclaw - Personal AI Assistant v%s\n\n", logo, version)
|
|
||||||
fmt.Println("Usage: picoclaw <command>")
|
|
||||||
fmt.Println()
|
|
||||||
fmt.Println("Commands:")
|
|
||||||
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
|
||||||
fmt.Println(" agent Interact with the agent directly")
|
|
||||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
|
||||||
fmt.Println(" gateway Start picoclaw gateway")
|
|
||||||
fmt.Println(" status Show picoclaw status")
|
|
||||||
fmt.Println(" cron Manage scheduled tasks")
|
|
||||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
|
||||||
fmt.Println(" skills Manage skills (install, list, remove)")
|
|
||||||
fmt.Println(" version Show version information")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getConfigPath() string {
|
|
||||||
home, _ := os.UserHomeDir()
|
|
||||||
return filepath.Join(home, ".picoclaw", "config.json")
|
|
||||||
}
|
|
||||||
|
|
||||||
func loadConfig() (*config.Config, error) {
|
|
||||||
return config.LoadConfig(getConfigPath())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewPicoclawCommand(t *testing.T) {
|
||||||
|
cmd := NewPicoclawCommand()
|
||||||
|
|
||||||
|
require.NotNil(t, cmd)
|
||||||
|
|
||||||
|
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
|
||||||
|
|
||||||
|
assert.Equal(t, "picoclaw", cmd.Use)
|
||||||
|
assert.Equal(t, short, cmd.Short)
|
||||||
|
|
||||||
|
assert.True(t, cmd.HasSubCommands())
|
||||||
|
assert.True(t, cmd.HasAvailableSubCommands())
|
||||||
|
|
||||||
|
assert.False(t, cmd.HasFlags())
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.Run)
|
||||||
|
assert.Nil(t, cmd.RunE)
|
||||||
|
|
||||||
|
assert.Nil(t, cmd.PersistentPreRun)
|
||||||
|
assert.Nil(t, cmd.PersistentPostRun)
|
||||||
|
|
||||||
|
allowedCommands := []string{
|
||||||
|
"agent",
|
||||||
|
"auth",
|
||||||
|
"cron",
|
||||||
|
"gateway",
|
||||||
|
"migrate",
|
||||||
|
"onboard",
|
||||||
|
"skills",
|
||||||
|
"status",
|
||||||
|
"version",
|
||||||
|
}
|
||||||
|
|
||||||
|
subcommands := cmd.Commands()
|
||||||
|
assert.Len(t, subcommands, len(allowedCommands))
|
||||||
|
|
||||||
|
for _, subcmd := range subcommands {
|
||||||
|
found := slices.Contains(allowedCommands, subcmd.Name())
|
||||||
|
assert.True(t, found, "unexpected subcommand %q", subcmd.Name())
|
||||||
|
|
||||||
|
assert.False(t, subcmd.Hidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-15
@@ -3,7 +3,7 @@
|
|||||||
"defaults": {
|
"defaults": {
|
||||||
"workspace": "~/.picoclaw/workspace",
|
"workspace": "~/.picoclaw/workspace",
|
||||||
"restrict_to_workspace": true,
|
"restrict_to_workspace": true,
|
||||||
"model": "gpt4",
|
"model_name": "gpt4",
|
||||||
"max_tokens": 8192,
|
"max_tokens": 8192,
|
||||||
"temperature": 0.7,
|
"temperature": 0.7,
|
||||||
"max_tool_iterations": 20
|
"max_tool_iterations": 20
|
||||||
@@ -52,30 +52,37 @@
|
|||||||
"proxy": "",
|
"proxy": "",
|
||||||
"allow_from": [
|
"allow_from": [
|
||||||
"YOUR_USER_ID"
|
"YOUR_USER_ID"
|
||||||
]
|
],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"discord": {
|
"discord": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"token": "YOUR_DISCORD_BOT_TOKEN",
|
"token": "YOUR_DISCORD_BOT_TOKEN",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"mention_only": false
|
"mention_only": false,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"qq": {
|
"qq": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"app_id": "YOUR_QQ_APP_ID",
|
"app_id": "YOUR_QQ_APP_ID",
|
||||||
"app_secret": "YOUR_QQ_APP_SECRET",
|
"app_secret": "YOUR_QQ_APP_SECRET",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"maixcam": {
|
"maixcam": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 18790,
|
"port": 18790,
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"whatsapp": {
|
"whatsapp": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bridge_url": "ws://localhost:3001",
|
"bridge_url": "ws://localhost:3001",
|
||||||
"allow_from": []
|
"use_native": false,
|
||||||
|
"session_store_path": "",
|
||||||
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"feishu": {
|
"feishu": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -83,19 +90,22 @@
|
|||||||
"app_secret": "",
|
"app_secret": "",
|
||||||
"encrypt_key": "",
|
"encrypt_key": "",
|
||||||
"verification_token": "",
|
"verification_token": "",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"dingtalk": {
|
"dingtalk": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"client_id": "YOUR_CLIENT_ID",
|
"client_id": "YOUR_CLIENT_ID",
|
||||||
"client_secret": "YOUR_CLIENT_SECRET",
|
"client_secret": "YOUR_CLIENT_SECRET",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"slack": {
|
"slack": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
"bot_token": "xoxb-YOUR-BOT-TOKEN",
|
||||||
"app_token": "xapp-YOUR-APP-TOKEN",
|
"app_token": "xapp-YOUR-APP-TOKEN",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"line": {
|
"line": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -104,7 +114,8 @@
|
|||||||
"webhook_host": "0.0.0.0",
|
"webhook_host": "0.0.0.0",
|
||||||
"webhook_port": 18791,
|
"webhook_port": 18791,
|
||||||
"webhook_path": "/webhook/line",
|
"webhook_path": "/webhook/line",
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"onebot": {
|
"onebot": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -112,7 +123,8 @@
|
|||||||
"access_token": "",
|
"access_token": "",
|
||||||
"reconnect_interval": 5,
|
"reconnect_interval": 5,
|
||||||
"group_trigger_prefix": [],
|
"group_trigger_prefix": [],
|
||||||
"allow_from": []
|
"allow_from": [],
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom": {
|
"wecom": {
|
||||||
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
|
||||||
@@ -124,7 +136,8 @@
|
|||||||
"webhook_port": 18793,
|
"webhook_port": 18793,
|
||||||
"webhook_path": "/webhook/wecom",
|
"webhook_path": "/webhook/wecom",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
},
|
},
|
||||||
"wecom_app": {
|
"wecom_app": {
|
||||||
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
|
||||||
@@ -138,7 +151,8 @@
|
|||||||
"webhook_port": 18792,
|
"webhook_port": 18792,
|
||||||
"webhook_path": "/webhook/wecom-app",
|
"webhook_path": "/webhook/wecom-app",
|
||||||
"allow_from": [],
|
"allow_from": [],
|
||||||
"reply_timeout": 5
|
"reply_timeout": 5,
|
||||||
|
"reasoning_channel_id": ""
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"providers": {
|
"providers": {
|
||||||
@@ -196,6 +210,10 @@
|
|||||||
"volcengine": {
|
"volcengine": {
|
||||||
"api_key": "",
|
"api_key": "",
|
||||||
"api_base": ""
|
"api_base": ""
|
||||||
|
},
|
||||||
|
"mistral": {
|
||||||
|
"api_key": "",
|
||||||
|
"api_base": "https://api.mistral.ai/v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
@@ -213,7 +231,8 @@
|
|||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "pplx-xxx",
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
}
|
},
|
||||||
|
"proxy": ""
|
||||||
},
|
},
|
||||||
"cron": {
|
"cron": {
|
||||||
"exec_timeout_minutes": 5
|
"exec_timeout_minutes": 5
|
||||||
@@ -288,7 +307,7 @@
|
|||||||
"monitor_usb": true
|
"monitor_usb": true
|
||||||
},
|
},
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"host": "0.0.0.0",
|
"host": "127.0.0.1",
|
||||||
"port": 18790
|
"port": 18790
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
# Stage 1: Build the picoclaw binary
|
# Stage 1: Build the picoclaw binary
|
||||||
# ============================================================
|
# ============================================================
|
||||||
FROM golang:1.26.0-alpine AS builder
|
FROM golang:1.25-alpine AS builder
|
||||||
|
|
||||||
RUN apk add --no-cache git make
|
RUN apk add --no-cache git make
|
||||||
|
|
||||||
@@ -5,6 +5,8 @@ ARG TARGETPLATFORM
|
|||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["picoclaw"]
|
RUN chmod +x /entrypoint.sh
|
||||||
CMD ["gateway"]
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Agent (one-shot query)
|
# PicoClaw Agent (one-shot query)
|
||||||
# docker compose run --rm picoclaw-agent -m "Hello"
|
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-agent:
|
picoclaw-agent:
|
||||||
build:
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-agent
|
container_name: picoclaw-agent
|
||||||
profiles:
|
profiles:
|
||||||
- agent
|
- agent
|
||||||
@@ -14,33 +12,23 @@ services:
|
|||||||
#extra_hosts:
|
#extra_hosts:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
- ./data:/root/.picoclaw
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
|
||||||
entrypoint: ["picoclaw", "agent"]
|
entrypoint: ["picoclaw", "agent"]
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Gateway (Long-running Bot)
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
# docker compose up picoclaw-gateway
|
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
build:
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-gateway
|
container_name: picoclaw-gateway
|
||||||
restart: unless-stopped
|
restart: on-failure
|
||||||
profiles:
|
profiles:
|
||||||
- gateway
|
- gateway
|
||||||
# Uncomment to access host network; leave commented unless needed.
|
# Uncomment to access host network; leave commented unless needed.
|
||||||
#extra_hosts:
|
#extra_hosts:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
# Configuration file
|
- ./data:/root/.picoclaw
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
|
||||||
# Persistent workspace (sessions, memory, logs)
|
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
|
||||||
command: ["gateway"]
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
picoclaw-workspace:
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# First-run: neither config nor workspace exists.
|
||||||
|
# If config.json is already mounted but workspace is missing we skip onboard to
|
||||||
|
# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container.
|
||||||
|
if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then
|
||||||
|
picoclaw onboard
|
||||||
|
echo ""
|
||||||
|
echo "First-run setup complete."
|
||||||
|
echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec picoclaw gateway "$@"
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Issue #783 调研与修复执行文档
|
||||||
|
|
||||||
|
## 1. 问题澄清(已确认)
|
||||||
|
|
||||||
|
- 现象:当 `agents.*.model.primary/fallbacks` 使用 `model_name` 别名(如 `step-3.5-flash`)时,fallback 链路将别名当作真实 `provider/model` 解析,导致 `provider` 可能为空、`model` 可能错误。
|
||||||
|
- 根因:`ResolveCandidates` 仅对字符串做 `ParseModelRef`,未先通过 `model_list` 将别名映射到真实 `model` 字段。
|
||||||
|
- 影响:
|
||||||
|
- fallback 执行可能把别名直接发给 OpenAI-compatible provider,触发 `Unknown Model`。
|
||||||
|
- `defaults.provider` 为空时,日志出现 `provider=` 空值。
|
||||||
|
|
||||||
|
## 2. 本次目标
|
||||||
|
|
||||||
|
- 修复 fallback 候选解析:优先通过 `model_list` 解析别名。
|
||||||
|
- 兼容旧行为:若未命中 `model_list`,继续走原有 `ParseModelRef` 兜底。
|
||||||
|
- 补充测试:覆盖别名、嵌套路径模型(如 `openrouter/stepfun/...`)、空默认 provider。
|
||||||
|
- 验证代码风格:与当前仓库风格保持一致(命名、错误处理、测试结构)。
|
||||||
|
|
||||||
|
## 3. 联网最佳实践调研结论(已完成)
|
||||||
|
|
||||||
|
- [x] 查阅 OpenAI-compatible 网关(如 OpenRouter)对 `model` 字段的推荐处理。
|
||||||
|
- [x] 查阅多 provider/fallback 设计最佳实践(候选解析、日志可观测性)。
|
||||||
|
- [x] 将外部建议映射为本仓库可执行约束。
|
||||||
|
|
||||||
|
外部参考要点(来自 OpenRouter/LiteLLM/Cloudflare AI Gateway 等官方文档):
|
||||||
|
|
||||||
|
- 优先显式配置,不依赖字符串切分推断 provider。
|
||||||
|
- 对网关模型标识应保留完整路径语义,避免截断导致 Unknown Model。
|
||||||
|
- fallback 与 primary 应复用同一解析策略,避免“主路径正确、降级路径错误”。
|
||||||
|
|
||||||
|
参考链接:
|
||||||
|
|
||||||
|
- OpenRouter Provider Routing: https://openrouter.ai/docs/guides/routing/provider-selection
|
||||||
|
- OpenRouter Model Fallbacks: https://openrouter.ai/docs/guides/routing/model-fallbacks
|
||||||
|
- OpenRouter Chat Completion API: https://openrouter.ai/docs/api-reference/chat-completion
|
||||||
|
- LiteLLM Router Architecture: https://docs.litellm.ai/docs/router_architecture
|
||||||
|
- Cloudflare AI Gateway Chat Completion: https://developers.cloudflare.com/ai-gateway/usage/chat-completion/
|
||||||
|
|
||||||
|
与本仓库对应的可执行约束:
|
||||||
|
|
||||||
|
- 在 fallback candidate 构建阶段先做 `model_name -> model_list.model` 映射。
|
||||||
|
- 未命中映射时保留旧解析行为,保证兼容性。
|
||||||
|
- 用新增测试锁定“别名 + 嵌套模型路径 + 空默认 provider”场景。
|
||||||
|
|
||||||
|
## 4. 实施步骤(顺序执行)
|
||||||
|
|
||||||
|
- [x] Step 1: 对齐现有代码模式,定位最小改动点(`pkg/agent` + `pkg/providers`)。
|
||||||
|
- [x] Step 2: 实现“基于 model_list 的 fallback 候选解析”。
|
||||||
|
- [x] Step 3: 增加/更新单元测试,覆盖 issue 场景。
|
||||||
|
- [x] Step 4: 代码风格一致性复核(与现有文件风格对照)。
|
||||||
|
- [x] Step 5: 运行质量门禁(LSP + `make check`)。
|
||||||
|
|
||||||
|
## 5. 执行记录
|
||||||
|
|
||||||
|
- 状态:已完成
|
||||||
|
- 已完成改动:
|
||||||
|
- `pkg/providers/fallback.go`:新增 `ResolveCandidatesWithLookup`,并保持 `ResolveCandidates` 向后兼容。
|
||||||
|
- `pkg/agent/instance.go`:在构建 fallback candidates 前,优先通过 `model_list` 解析别名,并对无协议模型补齐默认 `openai/` 前缀后再解析。
|
||||||
|
- `pkg/providers/fallback_test.go`:新增别名解析与去重测试。
|
||||||
|
- `pkg/agent/instance_test.go`:新增 agent 侧别名解析到嵌套模型路径、无协议模型解析测试。
|
||||||
|
- 风格对齐检查(完成):与 `pkg/providers/fallback_test.go`、`pkg/providers/model_ref_test.go` 现有模式一致。
|
||||||
|
- 质量验证(完成):先 `make generate`,后 `make check` 全量通过。
|
||||||
@@ -117,6 +117,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
|
|||||||
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
|
||||||
| `rpm` | No | Requests per minute limit |
|
| `rpm` | No | Requests per minute limit |
|
||||||
| `max_tokens_field` | No | Field name for max tokens |
|
| `max_tokens_field` | No | Field name for max tokens |
|
||||||
|
| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
|
||||||
|
|
||||||
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
|
||||||
|
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal
|
|
||||||
|
|
||||||
**Hello, PicoClaw Community!**
|
|
||||||
|
|
||||||
First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs!
|
|
||||||
|
|
||||||
PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum.
|
|
||||||
|
|
||||||
This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**.
|
|
||||||
|
|
||||||
### 🎁 Community Perks
|
|
||||||
|
|
||||||
To show our appreciation, developers who officially join our community operations will receive:
|
|
||||||
|
|
||||||
* **Exclusive AI Hardware:** Our upcoming, unreleased AI device.
|
|
||||||
* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers).
|
|
||||||
|
|
||||||
### 🎥 Calling All Content Creators!
|
|
||||||
|
|
||||||
Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**.
|
|
||||||
|
|
||||||
* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**.
|
|
||||||
* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM.
|
|
||||||
We will be rewarding high-quality content creators with the same perks as our community developers!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Urgent Volunteer Roles
|
|
||||||
|
|
||||||
We are looking for experts in the following areas:
|
|
||||||
|
|
||||||
1. **Issue/PR Reviewers**
|
|
||||||
* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging.
|
|
||||||
* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles.
|
|
||||||
|
|
||||||
|
|
||||||
2. **Resource Optimization Experts**
|
|
||||||
* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean.
|
|
||||||
* **Focus:** Analyzing resource growth between releases and trimming redundancy.
|
|
||||||
* **Priority:** **RAM usage optimization** > Binary size reduction.
|
|
||||||
|
|
||||||
|
|
||||||
3. **Security Audit & Bug Fixes**
|
|
||||||
* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management.
|
|
||||||
* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes.
|
|
||||||
|
|
||||||
|
|
||||||
4. **Documentation & DX (Developer Experience)**
|
|
||||||
* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow.
|
|
||||||
* **Focus:** Creating clear, user-friendly documentation for both setup and development.
|
|
||||||
|
|
||||||
|
|
||||||
5. **AI-Powered CI/CD Optimization**
|
|
||||||
* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it.
|
|
||||||
* **Focus:** Automating builds with AI and exploring AI-driven issue resolution.
|
|
||||||
|
|
||||||
**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role.
|
|
||||||
Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📍 The Roadmap
|
|
||||||
|
|
||||||
Interested in a specific feature? You can "claim" these tasks and start building:
|
|
||||||
|
|
||||||
###
|
|
||||||
* **Provider:**
|
|
||||||
* **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days)
|
|
||||||
* You can still submit code; Daming will merge it into the new implementation.
|
|
||||||
* **Channels:**
|
|
||||||
* Support for OneBot, additional platforms
|
|
||||||
* attachments (images, audio, video, files).
|
|
||||||
* **Skills:**
|
|
||||||
* Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms.
|
|
||||||
* **Operations:** * MCP Support.
|
|
||||||
* Android operations (e.g., botdrop).
|
|
||||||
* Browser automation via CDP or ActionBook.
|
|
||||||
|
|
||||||
|
|
||||||
* **Multi-Agent Ecosystem:**
|
|
||||||
* **Basic Model-Agent**
|
|
||||||
* **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
|
|
||||||
* **Swarm Mode.**
|
|
||||||
* **AIEOS Integration.**
|
|
||||||
|
|
||||||
|
|
||||||
* **Branding:**
|
|
||||||
* **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch!
|
|
||||||
|
|
||||||
|
|
||||||
We have officially created these tasks as GitHub Issues, all marked with the roadmap tag.
|
|
||||||
This list will be updated continuously as we progress.
|
|
||||||
If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 How to Join
|
|
||||||
|
|
||||||
**Everything is open to your creativity!** If you have a wild idea, just PR it.
|
|
||||||
|
|
||||||
1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw.
|
|
||||||
2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject:
|
|
||||||
> `[Apply Join PicoClaw Dev Group] + Your GitHub Account`
|
|
||||||
> Include the role you're interested in and any evidence of your development experience.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
### Looking Ahead
|
|
||||||
|
|
||||||
Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential.
|
|
||||||
|
|
||||||
**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Troubleshooting
|
||||||
|
|
||||||
|
## "model ... not found in model_list" or OpenRouter "free is not a valid model ID"
|
||||||
|
|
||||||
|
**Symptom:** You see either:
|
||||||
|
|
||||||
|
- `Error creating provider: model "openrouter/free" not found in model_list`
|
||||||
|
- OpenRouter returns 400: `"free is not a valid model ID"`
|
||||||
|
|
||||||
|
**Cause:** The `model` field in your `model_list` entry is what gets sent to the API. For OpenRouter you must use the **full** model ID, not a shorthand.
|
||||||
|
|
||||||
|
- **Wrong:** `"model": "free"` → OpenRouter receives `free` and rejects it.
|
||||||
|
- **Right:** `"model": "openrouter/free"` → OpenRouter receives `openrouter/free` (auto free-tier routing).
|
||||||
|
|
||||||
|
**Fix:** In `~/.picoclaw/config.json` (or your config path):
|
||||||
|
|
||||||
|
1. **agents.defaults.model** must match a `model_name` in `model_list` (e.g. `"openrouter-free"`).
|
||||||
|
2. That entry’s **model** must be a valid OpenRouter model ID, for example:
|
||||||
|
- `"openrouter/free"` – auto free-tier
|
||||||
|
- `"google/gemini-2.0-flash-exp:free"`
|
||||||
|
- `"meta-llama/llama-3.1-8b-instruct:free"`
|
||||||
|
|
||||||
|
Example snippet:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"agents": {
|
||||||
|
"defaults": {
|
||||||
|
"model": "openrouter-free"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model_list": [
|
||||||
|
{
|
||||||
|
"model_name": "openrouter-free",
|
||||||
|
"model": "openrouter/free",
|
||||||
|
"api_key": "sk-or-v1-YOUR_OPENROUTER_KEY",
|
||||||
|
"api_base": "https://openrouter.ai/api/v1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Get your key at [OpenRouter Keys](https://openrouter.ai/keys).
|
||||||
@@ -11,20 +11,49 @@ require (
|
|||||||
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/larksuite/oapi-sdk-go/v3 v3.5.3
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0
|
github.com/modelcontextprotocol/go-sdk v1.3.0
|
||||||
github.com/mymmrac/telego v1.6.0
|
github.com/mymmrac/telego v1.6.0
|
||||||
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/slack-go/slack v0.17.3
|
github.com/slack-go/slack v0.17.3
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
|
||||||
golang.org/x/oauth2 v0.35.0
|
golang.org/x/oauth2 v0.35.0
|
||||||
|
golang.org/x/time v0.14.0
|
||||||
|
google.golang.org/protobuf v1.36.11
|
||||||
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/beeper/argo-go v1.1.2 // indirect
|
||||||
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||||
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/rs/zerolog v1.34.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
|
||||||
|
go.mau.fi/libsignal v0.2.1 // indirect
|
||||||
|
go.mau.fi/util v0.9.6 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
|
golang.org/x/term v0.40.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
rsc.io/qr v0.2.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc=
|
||||||
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE=
|
||||||
|
github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs=
|
||||||
|
github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4=
|
||||||
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
|
||||||
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
@@ -25,11 +35,19 @@ 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/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/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg=
|
||||||
|
github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo=
|
||||||
github.com/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/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
|
||||||
@@ -41,6 +59,7 @@ github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2m
|
|||||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||||
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U=
|
||||||
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
@@ -64,6 +83,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
|||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
@@ -73,7 +94,11 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
|
|||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc=
|
||||||
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
|
||||||
@@ -90,10 +115,23 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
|||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
|
||||||
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4=
|
||||||
|
github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
|
github.com/modelcontextprotocol/go-sdk v1.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
|
github.com/modelcontextprotocol/go-sdk v1.3.0/go.mod h1:AnQ//Qc6+4nIyyrB4cxBU7UW9VibK4iOZBeyP/rF1IE=
|
||||||
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0=
|
||||||
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
@@ -106,14 +144,30 @@ github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv
|
|||||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
|
||||||
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
|
||||||
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -148,6 +202,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy
|
|||||||
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw=
|
||||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
|
||||||
@@ -155,8 +211,15 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0
|
|||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0=
|
||||||
|
go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU=
|
||||||
|
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
|
||||||
|
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
|
||||||
|
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
|
||||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
@@ -167,10 +230,14 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
|
|||||||
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
@@ -213,8 +280,11 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
@@ -223,6 +293,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
|||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
|
||||||
|
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||||
|
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -230,8 +302,10 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
|||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||||
@@ -239,8 +313,8 @@ golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4f
|
|||||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -253,6 +327,8 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
|||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
@@ -267,3 +343,33 @@ 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=
|
||||||
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||||
|
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||||
|
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||||
|
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||||
|
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||||
|
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
|
||||||
|
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
|
||||||
|
|||||||
+340
-80
@@ -1,24 +1,38 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"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/tools"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type ContextBuilder struct {
|
type ContextBuilder struct {
|
||||||
workspace string
|
workspace string
|
||||||
skillsLoader *skills.SkillsLoader
|
skillsLoader *skills.SkillsLoader
|
||||||
memory *MemoryStore
|
memory *MemoryStore
|
||||||
tools *tools.ToolRegistry // Direct reference to tool registry
|
|
||||||
|
// Cache for system prompt to avoid rebuilding on every call.
|
||||||
|
// This fixes issue #607: repeated reprocessing of the entire context.
|
||||||
|
// The cache auto-invalidates when workspace source files change (mtime check).
|
||||||
|
systemPromptMutex sync.RWMutex
|
||||||
|
cachedSystemPrompt string
|
||||||
|
cachedAt time.Time // max observed mtime across tracked paths at cache build time
|
||||||
|
|
||||||
|
// existedAtCache tracks which source file paths existed the last time the
|
||||||
|
// cache was built. This lets sourceFilesChanged detect files that are newly
|
||||||
|
// created (didn't exist at cache time, now exist) or deleted (existed at
|
||||||
|
// cache time, now gone) — both of which should trigger a cache rebuild.
|
||||||
|
existedAtCache map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func getGlobalConfigDir() string {
|
func getGlobalConfigDir() string {
|
||||||
@@ -43,69 +57,29 @@ func NewContextBuilder(workspace string) *ContextBuilder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
|
|
||||||
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
|
|
||||||
cb.tools = registry
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cb *ContextBuilder) getIdentity() string {
|
func (cb *ContextBuilder) getIdentity() string {
|
||||||
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
|
||||||
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
|
||||||
runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
|
||||||
|
|
||||||
// Build tools section dynamically
|
|
||||||
toolsSection := cb.buildToolsSection()
|
|
||||||
|
|
||||||
return fmt.Sprintf(`# picoclaw 🦞
|
return fmt.Sprintf(`# picoclaw 🦞
|
||||||
|
|
||||||
You are picoclaw, a helpful AI assistant.
|
You are picoclaw, a helpful AI assistant.
|
||||||
|
|
||||||
## Current Time
|
|
||||||
%s
|
|
||||||
|
|
||||||
## Runtime
|
|
||||||
%s
|
|
||||||
|
|
||||||
## Workspace
|
## Workspace
|
||||||
Your workspace is at: %s
|
Your workspace is at: %s
|
||||||
- Memory: %s/memory/MEMORY.md
|
- Memory: %s/memory/MEMORY.md
|
||||||
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
|
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
|
||||||
- Skills: %s/skills/{skill-name}/SKILL.md
|
- Skills: %s/skills/{skill-name}/SKILL.md
|
||||||
|
|
||||||
%s
|
|
||||||
|
|
||||||
## Important Rules
|
## Important Rules
|
||||||
|
|
||||||
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
|
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
|
||||||
|
|
||||||
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
|
||||||
|
|
||||||
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
|
3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md
|
||||||
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (cb *ContextBuilder) buildToolsSection() string {
|
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.`,
|
||||||
if cb.tools == nil {
|
workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
summaries := cb.tools.GetSummaries()
|
|
||||||
if len(summaries) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("## Available Tools\n\n")
|
|
||||||
sb.WriteString(
|
|
||||||
"**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
|
|
||||||
)
|
|
||||||
sb.WriteString("You have access to the following tools:\n\n")
|
|
||||||
for _, s := range summaries {
|
|
||||||
sb.WriteString(s)
|
|
||||||
sb.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
func (cb *ContextBuilder) BuildSystemPrompt() string {
|
||||||
@@ -140,6 +114,226 @@ The following skills extend your capabilities. To use a skill, read its SKILL.md
|
|||||||
return strings.Join(parts, "\n\n---\n\n")
|
return strings.Join(parts, "\n\n---\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BuildSystemPromptWithCache returns the cached system prompt if available
|
||||||
|
// and source files haven't changed, otherwise builds and caches it.
|
||||||
|
// Source file changes are detected via mtime checks (cheap stat calls).
|
||||||
|
func (cb *ContextBuilder) BuildSystemPromptWithCache() string {
|
||||||
|
// Try read lock first — fast path when cache is valid
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
|
||||||
|
result := cb.cachedSystemPrompt
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
|
||||||
|
// Acquire write lock for building
|
||||||
|
cb.systemPromptMutex.Lock()
|
||||||
|
defer cb.systemPromptMutex.Unlock()
|
||||||
|
|
||||||
|
// Double-check: another goroutine may have rebuilt while we waited
|
||||||
|
if cb.cachedSystemPrompt != "" && !cb.sourceFilesChangedLocked() {
|
||||||
|
return cb.cachedSystemPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot the baseline (existence + max mtime) BEFORE building the prompt.
|
||||||
|
// This way cachedAt reflects the pre-build state: if a file is modified
|
||||||
|
// during BuildSystemPrompt, its new mtime will be > baseline.maxMtime,
|
||||||
|
// so the next sourceFilesChangedLocked check will correctly trigger a
|
||||||
|
// rebuild. The alternative (baseline after build) risks caching stale
|
||||||
|
// content with a too-new baseline, making the staleness invisible.
|
||||||
|
baseline := cb.buildCacheBaseline()
|
||||||
|
prompt := cb.BuildSystemPrompt()
|
||||||
|
cb.cachedSystemPrompt = prompt
|
||||||
|
cb.cachedAt = baseline.maxMtime
|
||||||
|
cb.existedAtCache = baseline.existed
|
||||||
|
|
||||||
|
logger.DebugCF("agent", "System prompt cached",
|
||||||
|
map[string]any{
|
||||||
|
"length": len(prompt),
|
||||||
|
})
|
||||||
|
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
// InvalidateCache clears the cached system prompt.
|
||||||
|
// Normally not needed because the cache auto-invalidates via mtime checks,
|
||||||
|
// but this is useful for tests or explicit reload commands.
|
||||||
|
func (cb *ContextBuilder) InvalidateCache() {
|
||||||
|
cb.systemPromptMutex.Lock()
|
||||||
|
defer cb.systemPromptMutex.Unlock()
|
||||||
|
|
||||||
|
cb.cachedSystemPrompt = ""
|
||||||
|
cb.cachedAt = time.Time{}
|
||||||
|
cb.existedAtCache = nil
|
||||||
|
|
||||||
|
logger.DebugCF("agent", "System prompt cache invalidated", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourcePaths returns the workspace source file paths tracked for cache
|
||||||
|
// invalidation (bootstrap files + memory). The skills directory is handled
|
||||||
|
// separately in sourceFilesChangedLocked because it requires both directory-
|
||||||
|
// level and recursive file-level mtime checks.
|
||||||
|
func (cb *ContextBuilder) sourcePaths() []string {
|
||||||
|
return []string{
|
||||||
|
filepath.Join(cb.workspace, "AGENTS.md"),
|
||||||
|
filepath.Join(cb.workspace, "SOUL.md"),
|
||||||
|
filepath.Join(cb.workspace, "USER.md"),
|
||||||
|
filepath.Join(cb.workspace, "IDENTITY.md"),
|
||||||
|
filepath.Join(cb.workspace, "memory", "MEMORY.md"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cacheBaseline holds the file existence snapshot and the latest observed
|
||||||
|
// mtime across all tracked paths. Used as the cache reference point.
|
||||||
|
type cacheBaseline struct {
|
||||||
|
existed map[string]bool
|
||||||
|
maxMtime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCacheBaseline records which tracked paths currently exist and computes
|
||||||
|
// the latest mtime across all tracked files + skills directory contents.
|
||||||
|
// Called under write lock when the cache is built.
|
||||||
|
func (cb *ContextBuilder) buildCacheBaseline() cacheBaseline {
|
||||||
|
skillsDir := filepath.Join(cb.workspace, "skills")
|
||||||
|
|
||||||
|
// All paths whose existence we track: source files + skills dir.
|
||||||
|
allPaths := append(cb.sourcePaths(), skillsDir)
|
||||||
|
|
||||||
|
existed := make(map[string]bool, len(allPaths))
|
||||||
|
var maxMtime time.Time
|
||||||
|
|
||||||
|
for _, p := range allPaths {
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
existed[p] = err == nil
|
||||||
|
if err == nil && info.ModTime().After(maxMtime) {
|
||||||
|
maxMtime = info.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walk skills files to capture their mtimes too.
|
||||||
|
// Use os.Stat (not d.Info) to match the stat method used in
|
||||||
|
// fileChangedSince / skillFilesModifiedSince for consistency.
|
||||||
|
_ = filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr == nil && !d.IsDir() {
|
||||||
|
if info, err := os.Stat(path); err == nil && info.ModTime().After(maxMtime) {
|
||||||
|
maxMtime = info.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
// If no tracked files exist yet (empty workspace), maxMtime is zero.
|
||||||
|
// Use a very old non-zero time so that:
|
||||||
|
// 1. cachedAt.IsZero() won't trigger perpetual rebuilds.
|
||||||
|
// 2. Any real file created afterwards has mtime > cachedAt, so it
|
||||||
|
// will be detected by fileChangedSince (unlike time.Now() which
|
||||||
|
// could race with a file whose mtime <= Now).
|
||||||
|
if maxMtime.IsZero() {
|
||||||
|
maxMtime = time.Unix(1, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cacheBaseline{existed: existed, maxMtime: maxMtime}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sourceFilesChangedLocked checks whether any workspace source file has been
|
||||||
|
// modified, created, or deleted since the cache was last built.
|
||||||
|
//
|
||||||
|
// IMPORTANT: The caller MUST hold at least a read lock on systemPromptMutex.
|
||||||
|
// Go's sync.RWMutex is not reentrant, so this function must NOT acquire the
|
||||||
|
// lock itself (it would deadlock when called from BuildSystemPromptWithCache
|
||||||
|
// which already holds RLock or Lock).
|
||||||
|
func (cb *ContextBuilder) sourceFilesChangedLocked() bool {
|
||||||
|
if cb.cachedAt.IsZero() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tracked source files (bootstrap + memory).
|
||||||
|
for _, p := range cb.sourcePaths() {
|
||||||
|
if cb.fileChangedSince(p) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Skills directory (handled separately from sourcePaths) ---
|
||||||
|
//
|
||||||
|
// 1. Creation/deletion: tracked via existedAtCache, same as bootstrap files.
|
||||||
|
skillsDir := filepath.Join(cb.workspace, "skills")
|
||||||
|
if cb.fileChangedSince(skillsDir) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Structural changes (add/remove entries inside the dir) are reflected
|
||||||
|
// in the directory's own mtime, which fileChangedSince already checks.
|
||||||
|
//
|
||||||
|
// 3. Content-only edits to files inside skills/ do NOT update the parent
|
||||||
|
// directory mtime on most filesystems, so we recursively walk to check
|
||||||
|
// individual file mtimes at any nesting depth.
|
||||||
|
if skillFilesModifiedSince(skillsDir, cb.cachedAt) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// fileChangedSince returns true if a tracked source file has been modified,
|
||||||
|
// newly created, or deleted since the cache was built.
|
||||||
|
//
|
||||||
|
// Four cases:
|
||||||
|
// - existed at cache time, exists now -> check mtime
|
||||||
|
// - existed at cache time, gone now -> changed (deleted)
|
||||||
|
// - absent at cache time, exists now -> changed (created)
|
||||||
|
// - absent at cache time, gone now -> no change
|
||||||
|
func (cb *ContextBuilder) fileChangedSince(path string) bool {
|
||||||
|
// Defensive: if existedAtCache was never initialized, treat as changed
|
||||||
|
// so the cache rebuilds rather than silently serving stale data.
|
||||||
|
if cb.existedAtCache == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
existedBefore := cb.existedAtCache[path]
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
existsNow := err == nil
|
||||||
|
|
||||||
|
if existedBefore != existsNow {
|
||||||
|
return true // file was created or deleted
|
||||||
|
}
|
||||||
|
if !existsNow {
|
||||||
|
return false // didn't exist before, doesn't exist now
|
||||||
|
}
|
||||||
|
return info.ModTime().After(cb.cachedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errWalkStop is a sentinel error used to stop filepath.WalkDir early.
|
||||||
|
// Using a dedicated error (instead of fs.SkipAll) makes the early-exit
|
||||||
|
// intent explicit and avoids the nilerr linter warning that would fire
|
||||||
|
// if the callback returned nil when its err parameter is non-nil.
|
||||||
|
var errWalkStop = errors.New("walk stop")
|
||||||
|
|
||||||
|
// skillFilesModifiedSince recursively walks the skills directory and checks
|
||||||
|
// whether any file was modified after t. This catches content-only edits at
|
||||||
|
// any nesting depth (e.g. skills/name/docs/extra.md) that don't update
|
||||||
|
// parent directory mtimes.
|
||||||
|
func skillFilesModifiedSince(skillsDir string, t time.Time) bool {
|
||||||
|
changed := false
|
||||||
|
err := filepath.WalkDir(skillsDir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr == nil && !d.IsDir() {
|
||||||
|
if info, statErr := os.Stat(path); statErr == nil && info.ModTime().After(t) {
|
||||||
|
changed = true
|
||||||
|
return errWalkStop // stop walking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
// errWalkStop is expected (early exit on first changed file).
|
||||||
|
// os.IsNotExist means the skills dir doesn't exist yet — not an error.
|
||||||
|
// Any other error is unexpected and worth logging.
|
||||||
|
if err != nil && !errors.Is(err, errWalkStop) && !os.IsNotExist(err) {
|
||||||
|
logger.DebugCF("agent", "skills walk error", map[string]any{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
||||||
bootstrapFiles := []string{
|
bootstrapFiles := []string{
|
||||||
"AGENTS.md",
|
"AGENTS.md",
|
||||||
@@ -159,6 +353,28 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildDynamicContext returns a short dynamic context string with per-request info.
|
||||||
|
// This changes every request (time, session) so it is NOT part of the cached prompt.
|
||||||
|
// LLM-side KV cache reuse is achieved by each provider adapter's native mechanism:
|
||||||
|
// - Anthropic: per-block cache_control (ephemeral) on the static SystemParts block
|
||||||
|
// - OpenAI / Codex: prompt_cache_key for prefix-based caching
|
||||||
|
//
|
||||||
|
// See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
|
||||||
|
// See: https://platform.openai.com/docs/guides/prompt-caching
|
||||||
|
func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string {
|
||||||
|
now := time.Now().Format("2006-01-02 15:04 (Monday)")
|
||||||
|
rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt)
|
||||||
|
|
||||||
|
if channel != "" && chatID != "" {
|
||||||
|
fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) BuildMessages(
|
func (cb *ContextBuilder) BuildMessages(
|
||||||
history []providers.Message,
|
history []providers.Message,
|
||||||
summary string,
|
summary string,
|
||||||
@@ -168,23 +384,65 @@ func (cb *ContextBuilder) BuildMessages(
|
|||||||
) []providers.Message {
|
) []providers.Message {
|
||||||
messages := []providers.Message{}
|
messages := []providers.Message{}
|
||||||
|
|
||||||
systemPrompt := cb.BuildSystemPrompt()
|
// The static part (identity, bootstrap, skills, memory) is cached locally to
|
||||||
|
// avoid repeated file I/O and string building on every call (fixes issue #607).
|
||||||
|
// Dynamic parts (time, session, summary) are appended per request.
|
||||||
|
// Everything is sent as a single system message for provider compatibility:
|
||||||
|
// - Anthropic adapter extracts messages[0] (Role=="system") and maps its content
|
||||||
|
// to the top-level "system" parameter in the Messages API request. A single
|
||||||
|
// contiguous system block makes this extraction straightforward.
|
||||||
|
// - Codex maps only the first system message to its instructions field.
|
||||||
|
// - OpenAI-compat passes messages through as-is.
|
||||||
|
staticPrompt := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
// Add Current Session info if provided
|
// Build short dynamic context (time, runtime, session) — changes per request
|
||||||
if channel != "" && chatID != "" {
|
dynamicCtx := cb.buildDynamicContext(channel, chatID)
|
||||||
systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
|
|
||||||
|
// Compose a single system message: static (cached) + dynamic + optional summary.
|
||||||
|
// Keeping all system content in one message ensures every provider adapter can
|
||||||
|
// extract it correctly (Anthropic adapter -> top-level system param,
|
||||||
|
// Codex -> instructions field).
|
||||||
|
//
|
||||||
|
// SystemParts carries the same content as structured blocks so that
|
||||||
|
// cache-aware adapters (Anthropic) can set per-block cache_control.
|
||||||
|
// The static block is marked "ephemeral" — its prefix hash is stable
|
||||||
|
// across requests, enabling LLM-side KV cache reuse.
|
||||||
|
stringParts := []string{staticPrompt, dynamicCtx}
|
||||||
|
|
||||||
|
contentBlocks := []providers.ContentBlock{
|
||||||
|
{Type: "text", Text: staticPrompt, CacheControl: &providers.CacheControl{Type: "ephemeral"}},
|
||||||
|
{Type: "text", Text: dynamicCtx},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Log system prompt summary for debugging (debug mode only)
|
if summary != "" {
|
||||||
|
summaryText := fmt.Sprintf(
|
||||||
|
"CONTEXT_SUMMARY: The following is an approximate summary of prior conversation "+
|
||||||
|
"for reference only. It may be incomplete or outdated — always defer to explicit instructions.\n\n%s",
|
||||||
|
summary)
|
||||||
|
stringParts = append(stringParts, summaryText)
|
||||||
|
contentBlocks = append(contentBlocks, providers.ContentBlock{Type: "text", Text: summaryText})
|
||||||
|
}
|
||||||
|
|
||||||
|
fullSystemPrompt := strings.Join(stringParts, "\n\n---\n\n")
|
||||||
|
|
||||||
|
// Log system prompt summary for debugging (debug mode only).
|
||||||
|
// Read cachedSystemPrompt under lock to avoid a data race with
|
||||||
|
// concurrent InvalidateCache / BuildSystemPromptWithCache writes.
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
isCached := cb.cachedSystemPrompt != ""
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
|
||||||
logger.DebugCF("agent", "System prompt built",
|
logger.DebugCF("agent", "System prompt built",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"total_chars": len(systemPrompt),
|
"static_chars": len(staticPrompt),
|
||||||
"total_lines": strings.Count(systemPrompt, "\n") + 1,
|
"dynamic_chars": len(dynamicCtx),
|
||||||
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
|
"total_chars": len(fullSystemPrompt),
|
||||||
|
"has_summary": summary != "",
|
||||||
|
"cached": isCached,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Log preview of system prompt (avoid logging huge content)
|
// Log preview of system prompt (avoid logging huge content)
|
||||||
preview := systemPrompt
|
preview := fullSystemPrompt
|
||||||
if len(preview) > 500 {
|
if len(preview) > 500 {
|
||||||
preview = preview[:500] + "... (truncated)"
|
preview = preview[:500] + "... (truncated)"
|
||||||
}
|
}
|
||||||
@@ -193,19 +451,21 @@ func (cb *ContextBuilder) BuildMessages(
|
|||||||
"preview": preview,
|
"preview": preview,
|
||||||
})
|
})
|
||||||
|
|
||||||
if summary != "" {
|
|
||||||
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
|
|
||||||
}
|
|
||||||
|
|
||||||
history = sanitizeHistoryForProvider(history)
|
history = sanitizeHistoryForProvider(history)
|
||||||
|
|
||||||
|
// Single system message containing all context — compatible with all providers.
|
||||||
|
// SystemParts enables cache-aware adapters to set per-block cache_control;
|
||||||
|
// Content is the concatenated fallback for adapters that don't read SystemParts.
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "system",
|
Role: "system",
|
||||||
Content: systemPrompt,
|
Content: fullSystemPrompt,
|
||||||
|
SystemParts: contentBlocks,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Add conversation history
|
||||||
messages = append(messages, history...)
|
messages = append(messages, history...)
|
||||||
|
|
||||||
|
// Add current user message
|
||||||
if strings.TrimSpace(currentMessage) != "" {
|
if strings.TrimSpace(currentMessage) != "" {
|
||||||
messages = append(messages, providers.Message{
|
messages = append(messages, providers.Message{
|
||||||
Role: "user",
|
Role: "user",
|
||||||
@@ -224,13 +484,32 @@ func sanitizeHistoryForProvider(history []providers.Message) []providers.Message
|
|||||||
sanitized := make([]providers.Message, 0, len(history))
|
sanitized := make([]providers.Message, 0, len(history))
|
||||||
for _, msg := range history {
|
for _, msg := range history {
|
||||||
switch msg.Role {
|
switch msg.Role {
|
||||||
|
case "system":
|
||||||
|
// Drop system messages from history. BuildMessages always
|
||||||
|
// constructs its own single system message (static + dynamic +
|
||||||
|
// summary); extra system messages would break providers that
|
||||||
|
// only accept one (Anthropic, Codex).
|
||||||
|
logger.DebugCF("agent", "Dropping system message from history", map[string]any{})
|
||||||
|
continue
|
||||||
|
|
||||||
case "tool":
|
case "tool":
|
||||||
if len(sanitized) == 0 {
|
if len(sanitized) == 0 {
|
||||||
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
|
logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
last := sanitized[len(sanitized)-1]
|
// Walk backwards to find the nearest assistant message,
|
||||||
if last.Role != "assistant" || len(last.ToolCalls) == 0 {
|
// skipping over any preceding tool messages (multi-tool-call case).
|
||||||
|
foundAssistant := false
|
||||||
|
for i := len(sanitized) - 1; i >= 0; i-- {
|
||||||
|
if sanitized[i].Role == "tool" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sanitized[i].Role == "assistant" && len(sanitized[i].ToolCalls) > 0 {
|
||||||
|
foundAssistant = true
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !foundAssistant {
|
||||||
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
|
logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -288,25 +567,6 @@ func (cb *ContextBuilder) AddAssistantMessage(
|
|||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|
||||||
func (cb *ContextBuilder) loadSkills() string {
|
|
||||||
allSkills := cb.skillsLoader.ListSkills()
|
|
||||||
if len(allSkills) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var skillNames []string
|
|
||||||
for _, s := range allSkills {
|
|
||||||
skillNames = append(skillNames, s.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
content := cb.skillsLoader.LoadSkillsForContext(skillNames)
|
|
||||||
if content == "" {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return "# Skill Definitions\n\n" + content
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetSkillsInfo returns information about loaded skills.
|
// GetSkillsInfo returns information about loaded skills.
|
||||||
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
|
||||||
allSkills := cb.skillsLoader.ListSkills()
|
allSkills := cb.skillsLoader.ListSkills()
|
||||||
|
|||||||
@@ -0,0 +1,513 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupWorkspace creates a temporary workspace with standard directories and optional files.
|
||||||
|
// Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir).
|
||||||
|
func setupWorkspace(t *testing.T, files map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir, err := os.MkdirTemp("", "picoclaw-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
|
||||||
|
for name, content := range files {
|
||||||
|
dir := filepath.Dir(filepath.Join(tmpDir, name))
|
||||||
|
os.MkdirAll(dir, 0o755)
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tmpDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSingleSystemMessage verifies that BuildMessages always produces exactly one
|
||||||
|
// system message regardless of summary/history variations.
|
||||||
|
// Fix: multiple system messages break Anthropic (top-level system param) and
|
||||||
|
// Codex (only reads last system message as instructions).
|
||||||
|
func TestSingleSystemMessage(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
|
"IDENTITY.md": "# Identity\nTest agent.",
|
||||||
|
})
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
history []providers.Message
|
||||||
|
summary string
|
||||||
|
message string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no summary, no history",
|
||||||
|
summary: "",
|
||||||
|
message: "hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with summary",
|
||||||
|
summary: "Previous conversation discussed X",
|
||||||
|
message: "hello",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with history and summary",
|
||||||
|
history: []providers.Message{
|
||||||
|
{Role: "user", Content: "hi"},
|
||||||
|
{Role: "assistant", Content: "hello"},
|
||||||
|
},
|
||||||
|
summary: strings.Repeat("Long summary text. ", 50),
|
||||||
|
message: "new message",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "system message in history is filtered",
|
||||||
|
history: []providers.Message{
|
||||||
|
{Role: "system", Content: "stale system prompt from previous session"},
|
||||||
|
{Role: "user", Content: "hi"},
|
||||||
|
{Role: "assistant", Content: "hello"},
|
||||||
|
},
|
||||||
|
summary: "",
|
||||||
|
message: "new message",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
|
||||||
|
|
||||||
|
systemCount := 0
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == "system" {
|
||||||
|
systemCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if systemCount != 1 {
|
||||||
|
t.Errorf("expected exactly 1 system message, got %d", systemCount)
|
||||||
|
}
|
||||||
|
if msgs[0].Role != "system" {
|
||||||
|
t.Errorf("first message should be system, got %s", msgs[0].Role)
|
||||||
|
}
|
||||||
|
if msgs[len(msgs)-1].Role != "user" {
|
||||||
|
t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// System message must contain identity (static) and time (dynamic)
|
||||||
|
sys := msgs[0].Content
|
||||||
|
if !strings.Contains(sys, "picoclaw") {
|
||||||
|
t.Error("system message missing identity")
|
||||||
|
}
|
||||||
|
if !strings.Contains(sys, "Current Time") {
|
||||||
|
t.Error("system message missing dynamic time context")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary handling
|
||||||
|
if tt.summary != "" {
|
||||||
|
if !strings.Contains(sys, "CONTEXT_SUMMARY:") {
|
||||||
|
t.Error("summary present but CONTEXT_SUMMARY prefix missing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(sys, tt.summary[:20]) {
|
||||||
|
t.Error("summary content not found in system message")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if strings.Contains(sys, "CONTEXT_SUMMARY:") {
|
||||||
|
t.Error("CONTEXT_SUMMARY should not appear without summary")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMtimeAutoInvalidation verifies that the cache detects source file changes
|
||||||
|
// via mtime without requiring explicit InvalidateCache().
|
||||||
|
// Fix: original implementation had no auto-invalidation — edits to bootstrap files,
|
||||||
|
// memory, or skills were invisible until process restart.
|
||||||
|
func TestMtimeAutoInvalidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
file string // relative path inside workspace
|
||||||
|
contentV1 string
|
||||||
|
contentV2 string
|
||||||
|
checkField string // substring to verify in rebuilt prompt
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bootstrap file change",
|
||||||
|
file: "IDENTITY.md",
|
||||||
|
contentV1: "# Original Identity",
|
||||||
|
contentV2: "# Updated Identity",
|
||||||
|
checkField: "Updated Identity",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory file change",
|
||||||
|
file: "memory/MEMORY.md",
|
||||||
|
contentV1: "# Memory\nUser likes Go.",
|
||||||
|
contentV2: "# Memory\nUser likes Rust.",
|
||||||
|
checkField: "User likes Rust",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1})
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
|
// Overwrite file and set future mtime to ensure detection.
|
||||||
|
// Use 2s offset for filesystem mtime resolution safety (some FS
|
||||||
|
// have 1s or coarser granularity, especially in CI containers).
|
||||||
|
fullPath := filepath.Join(tmpDir, tt.file)
|
||||||
|
os.WriteFile(fullPath, []byte(tt.contentV2), 0o644)
|
||||||
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
os.Chtimes(fullPath, future, future)
|
||||||
|
|
||||||
|
// Verify sourceFilesChangedLocked detects the mtime change
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should auto-rebuild without explicit InvalidateCache()
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if sp1 == sp2 {
|
||||||
|
t.Errorf("cache not rebuilt after %s change", tt.file)
|
||||||
|
}
|
||||||
|
if !strings.Contains(sp2, tt.checkField) {
|
||||||
|
t.Errorf("rebuilt prompt missing expected content %q", tt.checkField)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills directory mtime change
|
||||||
|
t.Run("skills dir change", func(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
_ = cb.BuildSystemPromptWithCache() // populate cache
|
||||||
|
|
||||||
|
// Touch skills directory (simulate new skill installed)
|
||||||
|
skillsDir := filepath.Join(tmpDir, "skills")
|
||||||
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
os.Chtimes(skillsDir, future, future)
|
||||||
|
|
||||||
|
// Verify sourceFilesChangedLocked detects it (cache is rebuilt)
|
||||||
|
// We confirm by checking internal state: a second call should rebuild.
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Error("sourceFilesChangedLocked() should detect skills dir mtime change")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild
|
||||||
|
// even when source files haven't changed (useful for tests and reload commands).
|
||||||
|
func TestExplicitInvalidateCache(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
|
"IDENTITY.md": "# Test Identity",
|
||||||
|
})
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
cb.InvalidateCache()
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
|
if sp1 != sp2 {
|
||||||
|
t.Error("prompt should be identical after invalidate+rebuild when files unchanged")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify cachedAt was reset
|
||||||
|
cb.InvalidateCache()
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
if !cb.cachedAt.IsZero() {
|
||||||
|
t.Error("cachedAt should be zero after InvalidateCache()")
|
||||||
|
}
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCacheStability verifies that the static prompt is stable across repeated calls
|
||||||
|
// when no files change (regression test for issue #607).
|
||||||
|
func TestCacheStability(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
|
"IDENTITY.md": "# Identity\nContent",
|
||||||
|
"SOUL.md": "# Soul\nContent",
|
||||||
|
})
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
|
results := make([]string, 5)
|
||||||
|
for i := range results {
|
||||||
|
results[i] = cb.BuildSystemPromptWithCache()
|
||||||
|
}
|
||||||
|
for i := 1; i < len(results); i++ {
|
||||||
|
if results[i] != results[0] {
|
||||||
|
t.Errorf("cached prompt changed between call 0 and %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static prompt must NOT contain per-request data
|
||||||
|
if strings.Contains(results[0], "Current Time") {
|
||||||
|
t.Error("static cached prompt should not contain time (added dynamically)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewFileCreationInvalidatesCache verifies that creating a source file that
|
||||||
|
// did not exist when the cache was built triggers a cache rebuild.
|
||||||
|
// This catches the "from nothing to something" edge case that the old
|
||||||
|
// modifiedSince (return false on stat error) would miss.
|
||||||
|
func TestNewFileCreationInvalidatesCache(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
file string // relative path inside workspace
|
||||||
|
content string
|
||||||
|
checkField string // substring to verify in rebuilt prompt
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "new bootstrap file",
|
||||||
|
file: "SOUL.md",
|
||||||
|
content: "# Soul\nBe kind and helpful.",
|
||||||
|
checkField: "Be kind and helpful",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new memory file",
|
||||||
|
file: "memory/MEMORY.md",
|
||||||
|
content: "# Memory\nUser prefers dark mode.",
|
||||||
|
checkField: "User prefers dark mode",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Start with an empty workspace (no bootstrap/memory files)
|
||||||
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
|
// Populate cache — file does not exist yet
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
if strings.Contains(sp1, tt.checkField) {
|
||||||
|
t.Fatalf("prompt should not contain %q before file is created", tt.checkField)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the file after cache was built
|
||||||
|
fullPath := filepath.Join(tmpDir, tt.file)
|
||||||
|
os.MkdirAll(filepath.Dir(fullPath), 0o755)
|
||||||
|
if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Set future mtime to guarantee detection
|
||||||
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
os.Chtimes(fullPath, future, future)
|
||||||
|
|
||||||
|
// Cache should auto-invalidate because file went from absent -> present
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp2, tt.checkField) {
|
||||||
|
t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSkillFileContentChange verifies that modifying a skill file's content
|
||||||
|
// (not just the directory structure) invalidates the cache.
|
||||||
|
// This is the scenario where directory mtime alone is insufficient — on most
|
||||||
|
// filesystems, editing a file inside a directory does NOT update the parent
|
||||||
|
// directory's mtime.
|
||||||
|
func TestSkillFileContentChange(t *testing.T) {
|
||||||
|
skillMD := `---
|
||||||
|
name: test-skill
|
||||||
|
description: "A test skill"
|
||||||
|
---
|
||||||
|
# Test Skill v1
|
||||||
|
Original content.`
|
||||||
|
|
||||||
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
|
"skills/test-skill/SKILL.md": skillMD,
|
||||||
|
})
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
|
// Populate cache
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
_ = sp1 // cache is warm
|
||||||
|
|
||||||
|
// Modify the skill file content (without touching the skills/ directory)
|
||||||
|
updatedSkillMD := `---
|
||||||
|
name: test-skill
|
||||||
|
description: "An updated test skill"
|
||||||
|
---
|
||||||
|
# Test Skill v2
|
||||||
|
Updated content.`
|
||||||
|
|
||||||
|
skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md")
|
||||||
|
if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Set future mtime on the skill file only (NOT the directory)
|
||||||
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
os.Chtimes(skillPath, future, future)
|
||||||
|
|
||||||
|
// Verify that sourceFilesChangedLocked detects the content change
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Error("sourceFilesChangedLocked() should detect skill file content change")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify cache is actually rebuilt with new content
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if sp1 == sp2 && strings.Contains(sp1, "test-skill") {
|
||||||
|
// If the skill appeared in the prompt and the prompt didn't change,
|
||||||
|
// the cache was not invalidated.
|
||||||
|
t.Error("cache should be invalidated when skill file content changes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
|
||||||
|
// can safely call BuildSystemPromptWithCache concurrently without producing
|
||||||
|
// empty results, panics, or data races.
|
||||||
|
// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
|
||||||
|
func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
|
||||||
|
tmpDir := setupWorkspace(t, map[string]string{
|
||||||
|
"IDENTITY.md": "# Identity\nConcurrency test agent.",
|
||||||
|
"SOUL.md": "# Soul\nBe helpful.",
|
||||||
|
"memory/MEMORY.md": "# Memory\nUser prefers Go.",
|
||||||
|
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
|
||||||
|
})
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
|
const goroutines = 20
|
||||||
|
const iterations = 50
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make(chan string, goroutines*iterations)
|
||||||
|
|
||||||
|
for g := 0; g < goroutines; g++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for i := 0; i < iterations; i++ {
|
||||||
|
result := cb.BuildSystemPromptWithCache()
|
||||||
|
if result == "" {
|
||||||
|
errs <- "empty prompt returned"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !strings.Contains(result, "picoclaw") {
|
||||||
|
errs <- "prompt missing identity"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also exercise BuildMessages concurrently
|
||||||
|
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
|
||||||
|
if len(msgs) < 2 {
|
||||||
|
errs <- "BuildMessages returned fewer than 2 messages"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgs[0].Role != "system" {
|
||||||
|
errs <- "first message not system"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Occasionally invalidate to exercise the write path
|
||||||
|
if i%10 == 0 {
|
||||||
|
cb.InvalidateCache()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errs)
|
||||||
|
|
||||||
|
for errMsg := range errs {
|
||||||
|
t.Errorf("concurrent access error: %s", errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkBuildMessagesWithCache measures caching performance.
|
||||||
|
|
||||||
|
// TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is
|
||||||
|
// built on an empty workspace (no tracked files exist), creating a file
|
||||||
|
// afterwards still triggers cache invalidation. This validates the
|
||||||
|
// time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch,
|
||||||
|
// so fileChangedSince correctly detects the absent -> present transition AND
|
||||||
|
// the mtime comparison succeeds even without artificially inflated Chtimes.
|
||||||
|
func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
|
||||||
|
// Empty workspace: no bootstrap files, no memory, no skills content.
|
||||||
|
tmpDir := setupWorkspace(t, nil)
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
|
||||||
|
// Build cache — all tracked files are absent, maxMtime falls back to epoch.
|
||||||
|
sp1 := cb.BuildSystemPromptWithCache()
|
||||||
|
|
||||||
|
// Create a bootstrap file with natural mtime (no Chtimes manipulation).
|
||||||
|
// The file's mtime should be the current wall-clock time, which is
|
||||||
|
// strictly after time.Unix(1, 0).
|
||||||
|
soulPath := filepath.Join(tmpDir, "SOUL.md")
|
||||||
|
if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache should detect the new file via existedAtCache (absent -> present).
|
||||||
|
cb.systemPromptMutex.RLock()
|
||||||
|
changed := cb.sourceFilesChangedLocked()
|
||||||
|
cb.systemPromptMutex.RUnlock()
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace")
|
||||||
|
}
|
||||||
|
|
||||||
|
sp2 := cb.BuildSystemPromptWithCache()
|
||||||
|
if !strings.Contains(sp2, "Newly created") {
|
||||||
|
t.Error("rebuilt prompt should contain new file content")
|
||||||
|
}
|
||||||
|
if sp1 == sp2 {
|
||||||
|
t.Error("cache should have been invalidated after file creation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkBuildMessagesWithCache measures caching performance.
|
||||||
|
func BenchmarkBuildMessagesWithCache(b *testing.B) {
|
||||||
|
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
|
||||||
|
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
|
||||||
|
for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
|
||||||
|
os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb := NewContextBuilder(tmpDir)
|
||||||
|
history := []providers.Message{
|
||||||
|
{Role: "user", Content: "previous message"},
|
||||||
|
{Role: "assistant", Content: "previous response"},
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func msg(role, content string) providers.Message {
|
||||||
|
return providers.Message{Role: role, Content: content}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assistantWithTools(toolIDs ...string) providers.Message {
|
||||||
|
calls := make([]providers.ToolCall, len(toolIDs))
|
||||||
|
for i, id := range toolIDs {
|
||||||
|
calls[i] = providers.ToolCall{ID: id, Type: "function"}
|
||||||
|
}
|
||||||
|
return providers.Message{Role: "assistant", ToolCalls: calls}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolResult(id string) providers.Message {
|
||||||
|
return providers.Message{Role: "tool", Content: "result", ToolCallID: id}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
|
||||||
|
result := sanitizeHistoryForProvider(nil)
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Fatalf("expected empty, got %d messages", len(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
result = sanitizeHistoryForProvider([]providers.Message{})
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Fatalf("expected empty, got %d messages", len(result))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "hello"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
toolResult("A"),
|
||||||
|
msg("assistant", "done"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 4 {
|
||||||
|
t.Fatalf("expected 4 messages, got %d", len(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do two things"),
|
||||||
|
assistantWithTools("A", "B"),
|
||||||
|
toolResult("A"),
|
||||||
|
toolResult("B"),
|
||||||
|
msg("assistant", "both done"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 5 {
|
||||||
|
t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "hi"),
|
||||||
|
msg("assistant", "thinking"),
|
||||||
|
assistantWithTools("A"),
|
||||||
|
toolResult("A"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
toolResult("A"),
|
||||||
|
msg("user", "hello"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "hello"),
|
||||||
|
toolResult("A"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "hello"),
|
||||||
|
msg("assistant", "hi"),
|
||||||
|
toolResult("A"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 2 {
|
||||||
|
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
assistantWithTools("A"),
|
||||||
|
toolResult("A"),
|
||||||
|
msg("user", "hello"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 1 {
|
||||||
|
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "do two things"),
|
||||||
|
assistantWithTools("A", "B"),
|
||||||
|
toolResult("A"),
|
||||||
|
toolResult("B"),
|
||||||
|
msg("assistant", "done"),
|
||||||
|
msg("user", "hi"),
|
||||||
|
assistantWithTools("C"),
|
||||||
|
toolResult("C"),
|
||||||
|
msg("assistant", "done again"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 9 {
|
||||||
|
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "start"),
|
||||||
|
assistantWithTools("A", "B"),
|
||||||
|
toolResult("A"),
|
||||||
|
toolResult("B"),
|
||||||
|
assistantWithTools("C", "D"),
|
||||||
|
toolResult("C"),
|
||||||
|
toolResult("D"),
|
||||||
|
msg("assistant", "all done"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 8 {
|
||||||
|
t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
|
||||||
|
history := []providers.Message{
|
||||||
|
msg("user", "hello"),
|
||||||
|
msg("assistant", "hi"),
|
||||||
|
msg("user", "how are you"),
|
||||||
|
msg("assistant", "fine"),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sanitizeHistoryForProvider(history)
|
||||||
|
if len(result) != 4 {
|
||||||
|
t.Fatalf("expected 4 messages, got %d", len(result))
|
||||||
|
}
|
||||||
|
assertRoles(t, result, "user", "assistant", "user", "assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
func roles(msgs []providers.Message) []string {
|
||||||
|
r := make([]string, len(msgs))
|
||||||
|
for i, m := range msgs {
|
||||||
|
r[i] = m.Role
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
|
||||||
|
t.Helper()
|
||||||
|
if len(msgs) != len(expected) {
|
||||||
|
t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected)
|
||||||
|
}
|
||||||
|
for i, exp := range expected {
|
||||||
|
if msgs[i].Role != exp {
|
||||||
|
t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-3
@@ -59,7 +59,6 @@ func NewAgentInstance(
|
|||||||
sessionsManager := session.NewSessionManager(sessionsDir)
|
sessionsManager := session.NewSessionManager(sessionsDir)
|
||||||
|
|
||||||
contextBuilder := NewContextBuilder(workspace)
|
contextBuilder := NewContextBuilder(workspace)
|
||||||
contextBuilder.SetToolsRegistry(toolsRegistry)
|
|
||||||
|
|
||||||
agentID := routing.DefaultAgentID
|
agentID := routing.DefaultAgentID
|
||||||
agentName := ""
|
agentName := ""
|
||||||
@@ -93,7 +92,47 @@ func NewAgentInstance(
|
|||||||
Primary: model,
|
Primary: model,
|
||||||
Fallbacks: fallbacks,
|
Fallbacks: fallbacks,
|
||||||
}
|
}
|
||||||
candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
|
resolveFromModelList := func(raw string) (string, bool) {
|
||||||
|
ensureProtocol := func(model string) string {
|
||||||
|
model = strings.TrimSpace(model)
|
||||||
|
if model == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.Contains(model, "/") {
|
||||||
|
return model
|
||||||
|
}
|
||||||
|
return "openai/" + model
|
||||||
|
}
|
||||||
|
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg != nil {
|
||||||
|
if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" {
|
||||||
|
return ensureProtocol(mc.Model), true
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range cfg.ModelList {
|
||||||
|
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
|
||||||
|
if fullModel == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if fullModel == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
_, modelID := providers.ExtractProtocol(fullModel)
|
||||||
|
if modelID == raw {
|
||||||
|
return ensureProtocol(fullModel), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
|
||||||
|
|
||||||
return &AgentInstance{
|
return &AgentInstance{
|
||||||
ID: agentID,
|
ID: agentID,
|
||||||
@@ -133,7 +172,7 @@ func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefau
|
|||||||
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
|
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
|
||||||
return strings.TrimSpace(agentCfg.Model.Primary)
|
return strings.TrimSpace(agentCfg.Model.Primary)
|
||||||
}
|
}
|
||||||
return defaults.Model
|
return defaults.GetModelName()
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAgentFallbacks resolves the fallback models for an agent.
|
// resolveAgentFallbacks resolves the fallback models for an agent.
|
||||||
|
|||||||
@@ -93,3 +93,77 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
|
|||||||
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "step-3.5-flash",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "step-3.5-flash",
|
||||||
|
Model: "openrouter/stepfun/step-3.5-flash:free",
|
||||||
|
APIBase: "https://openrouter.ai/api/v1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != "openrouter" {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "glm-5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ModelList: []config.ModelConfig{
|
||||||
|
{
|
||||||
|
ModelName: "glm-5",
|
||||||
|
Model: "glm-5",
|
||||||
|
APIBase: "https://api.z.ai/api/coding/paas/v4",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := &mockProvider{}
|
||||||
|
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
|
||||||
|
|
||||||
|
if len(agent.Candidates) != 1 {
|
||||||
|
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Provider != "openai" {
|
||||||
|
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
|
||||||
|
}
|
||||||
|
if agent.Candidates[0].Model != "glm-5" {
|
||||||
|
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+173
-26
@@ -10,6 +10,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -22,6 +23,7 @@ import (
|
|||||||
"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/mcp"
|
||||||
|
"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"
|
||||||
"github.com/sipeed/picoclaw/pkg/skills"
|
"github.com/sipeed/picoclaw/pkg/skills"
|
||||||
@@ -39,6 +41,7 @@ type AgentLoop struct {
|
|||||||
summarizing sync.Map
|
summarizing sync.Map
|
||||||
fallback *providers.FallbackChain
|
fallback *providers.FallbackChain
|
||||||
channelManager *channels.Manager
|
channelManager *channels.Manager
|
||||||
|
mediaStore media.MediaStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOptions configures how a message is processed
|
// processOptions configures how a message is processed
|
||||||
@@ -53,6 +56,8 @@ type processOptions struct {
|
|||||||
NoHistory bool // If true, don't load session history (for heartbeat)
|
NoHistory bool // If true, don't load session history (for heartbeat)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
|
||||||
|
|
||||||
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
|
||||||
registry := NewAgentRegistry(cfg, provider)
|
registry := NewAgentRegistry(cfg, provider)
|
||||||
|
|
||||||
@@ -98,15 +103,20 @@ func registerSharedTools(
|
|||||||
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
|
||||||
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,
|
||||||
|
TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
|
||||||
|
TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
|
||||||
|
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,
|
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
|
Proxy: cfg.Tools.Web.Proxy,
|
||||||
}); searchTool != nil {
|
}); searchTool != nil {
|
||||||
agent.Tools.Register(searchTool)
|
agent.Tools.Register(searchTool)
|
||||||
}
|
}
|
||||||
agent.Tools.Register(tools.NewWebFetchTool(50000))
|
agent.Tools.Register(tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy))
|
||||||
|
|
||||||
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
|
||||||
agent.Tools.Register(tools.NewI2CTool())
|
agent.Tools.Register(tools.NewI2CTool())
|
||||||
@@ -115,12 +125,13 @@ func registerSharedTools(
|
|||||||
// Message tool
|
// Message tool
|
||||||
messageTool := tools.NewMessageTool()
|
messageTool := tools.NewMessageTool()
|
||||||
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
messageTool.SetSendCallback(func(channel, chatID, content string) error {
|
||||||
msgBus.PublishOutbound(bus.OutboundMessage{
|
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer pubCancel()
|
||||||
|
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
Content: content,
|
Content: content,
|
||||||
})
|
})
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
agent.Tools.Register(messageTool)
|
agent.Tools.Register(messageTool)
|
||||||
|
|
||||||
@@ -145,9 +156,6 @@ func registerSharedTools(
|
|||||||
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
|
||||||
})
|
})
|
||||||
agent.Tools.Register(spawnTool)
|
agent.Tools.Register(spawnTool)
|
||||||
|
|
||||||
// Update context builder with the complete tools registry
|
|
||||||
agent.ContextBuilder.SetToolsRegistry(agent.Tools)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,6 +237,21 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process message
|
||||||
|
func() {
|
||||||
|
// TODO: Re-enable media cleanup after inbound media is properly consumed by the agent.
|
||||||
|
// Currently disabled because files are deleted before the LLM can access their content.
|
||||||
|
// defer func() {
|
||||||
|
// if al.mediaStore != nil && msg.MediaScope != "" {
|
||||||
|
// if releaseErr := al.mediaStore.ReleaseAll(msg.MediaScope); releaseErr != nil {
|
||||||
|
// logger.WarnCF("agent", "Failed to release media", map[string]any{
|
||||||
|
// "scope": msg.MediaScope,
|
||||||
|
// "error": releaseErr.Error(),
|
||||||
|
// })
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }()
|
||||||
|
|
||||||
response, err := al.processMessage(ctx, msg)
|
response, err := al.processMessage(ctx, msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response = fmt.Sprintf("Error processing message: %v", err)
|
response = fmt.Sprintf("Error processing message: %v", err)
|
||||||
@@ -249,13 +272,26 @@ func (al *AgentLoop) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !alreadySent {
|
if !alreadySent {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
Content: response,
|
Content: response,
|
||||||
})
|
})
|
||||||
|
logger.InfoCF("agent", "Published outbound response",
|
||||||
|
map[string]any{
|
||||||
|
"channel": msg.Channel,
|
||||||
|
"chat_id": msg.ChatID,
|
||||||
|
"content_len": len(response),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logger.DebugCF(
|
||||||
|
"agent",
|
||||||
|
"Skipped outbound (message tool already sent)",
|
||||||
|
map[string]any{"channel": msg.Channel},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +314,41 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
|
|||||||
al.channelManager = cm
|
al.channelManager = cm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMediaStore injects a MediaStore for media lifecycle management.
|
||||||
|
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
|
||||||
|
al.mediaStore = s
|
||||||
|
}
|
||||||
|
|
||||||
|
// inferMediaType determines the media type ("image", "audio", "video", "file")
|
||||||
|
// from a filename and MIME content type.
|
||||||
|
func inferMediaType(filename, contentType string) string {
|
||||||
|
ct := strings.ToLower(contentType)
|
||||||
|
fn := strings.ToLower(filename)
|
||||||
|
|
||||||
|
if strings.HasPrefix(ct, "image/") {
|
||||||
|
return "image"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ct, "audio/") || ct == "application/ogg" {
|
||||||
|
return "audio"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(ct, "video/") {
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: infer from extension
|
||||||
|
ext := filepath.Ext(fn)
|
||||||
|
switch ext {
|
||||||
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg":
|
||||||
|
return "image"
|
||||||
|
case ".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma", ".opus":
|
||||||
|
return "audio"
|
||||||
|
case ".mp4", ".avi", ".mov", ".webm", ".mkv":
|
||||||
|
return "video"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "file"
|
||||||
|
}
|
||||||
|
|
||||||
// RecordLastChannel records the last active channel for this workspace.
|
// RecordLastChannel records the last active channel for this workspace.
|
||||||
// This uses the atomic state save mechanism to prevent data loss on crash.
|
// This uses the atomic state save mechanism to prevent data loss on crash.
|
||||||
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
func (al *AgentLoop) RecordLastChannel(channel string) error {
|
||||||
@@ -319,12 +390,15 @@ func (al *AgentLoop) ProcessDirectWithChannel(
|
|||||||
// Each heartbeat is independent and doesn't accumulate context.
|
// Each heartbeat is independent and doesn't accumulate context.
|
||||||
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
|
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
|
||||||
agent := al.registry.GetDefaultAgent()
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no default agent for heartbeat")
|
||||||
|
}
|
||||||
return al.runAgentLoop(ctx, agent, processOptions{
|
return al.runAgentLoop(ctx, agent, processOptions{
|
||||||
SessionKey: "heartbeat",
|
SessionKey: "heartbeat",
|
||||||
Channel: channel,
|
Channel: channel,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
UserMessage: content,
|
UserMessage: content,
|
||||||
DefaultResponse: "I've completed processing but have no response to give.",
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: false,
|
EnableSummary: false,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
NoHistory: true, // Don't load session history for heartbeat
|
NoHistory: true, // Don't load session history for heartbeat
|
||||||
@@ -371,6 +445,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||||||
if !ok {
|
if !ok {
|
||||||
agent = al.registry.GetDefaultAgent()
|
agent = al.registry.GetDefaultAgent()
|
||||||
}
|
}
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no agent available for route (agent_id=%s)", route.AgentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset message-tool state for this round so we don't skip publishing due to a previous round.
|
||||||
|
if tool, ok := agent.Tools.Get("message"); ok {
|
||||||
|
if mt, ok := tool.(tools.ContextualTool); ok {
|
||||||
|
mt.SetContext(msg.Channel, msg.ChatID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
|
// Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
|
||||||
sessionKey := route.SessionKey
|
sessionKey := route.SessionKey
|
||||||
@@ -390,7 +474,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
|
|||||||
Channel: msg.Channel,
|
Channel: msg.Channel,
|
||||||
ChatID: msg.ChatID,
|
ChatID: msg.ChatID,
|
||||||
UserMessage: msg.Content,
|
UserMessage: msg.Content,
|
||||||
DefaultResponse: "I've completed processing but have no response to give.",
|
DefaultResponse: defaultResponse,
|
||||||
EnableSummary: true,
|
EnableSummary: true,
|
||||||
SendResponse: false,
|
SendResponse: false,
|
||||||
})
|
})
|
||||||
@@ -437,6 +521,9 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
|
|||||||
|
|
||||||
// Use default agent for system messages
|
// Use default agent for system messages
|
||||||
agent := al.registry.GetDefaultAgent()
|
agent := al.registry.GetDefaultAgent()
|
||||||
|
if agent == nil {
|
||||||
|
return "", fmt.Errorf("no default agent for system message")
|
||||||
|
}
|
||||||
|
|
||||||
// Use the origin session for context
|
// Use the origin session for context
|
||||||
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
|
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
|
||||||
@@ -512,7 +599,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
|||||||
|
|
||||||
// 8. Optional: send response via bus
|
// 8. Optional: send response via bus
|
||||||
if opts.SendResponse {
|
if opts.SendResponse {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Content: finalContent,
|
Content: finalContent,
|
||||||
@@ -532,6 +619,34 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
|||||||
return finalContent, nil
|
return finalContent, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
|
||||||
|
if al.channelManager == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if ch, ok := al.channelManager.GetChannel(channelName); ok {
|
||||||
|
return ch.ReasoningChannelID()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) {
|
||||||
|
if reasoningContent == "" || channelName == "" || channelID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check context cancellation before attempting to publish,
|
||||||
|
// since PublishOutbound's select may race between send and ctx.Done().
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
|
Channel: channelName,
|
||||||
|
ChatID: channelID,
|
||||||
|
Content: reasoningContent,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// runLLMIteration executes the LLM call loop with tool handling.
|
// runLLMIteration executes the LLM call loop with tool handling.
|
||||||
func (al *AgentLoop) runLLMIteration(
|
func (al *AgentLoop) runLLMIteration(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
@@ -587,6 +702,7 @@ func (al *AgentLoop) runLLMIteration(
|
|||||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
|
||||||
"max_tokens": agent.MaxTokens,
|
"max_tokens": agent.MaxTokens,
|
||||||
"temperature": agent.Temperature,
|
"temperature": agent.Temperature,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -603,6 +719,7 @@ func (al *AgentLoop) runLLMIteration(
|
|||||||
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
|
return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
|
||||||
"max_tokens": agent.MaxTokens,
|
"max_tokens": agent.MaxTokens,
|
||||||
"temperature": agent.Temperature,
|
"temperature": agent.Temperature,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,7 +744,7 @@ func (al *AgentLoop) runLLMIteration(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
|
if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Content: "Context window exceeded. Compressing history and retrying...",
|
Content: "Context window exceeded. Compressing history and retrying...",
|
||||||
@@ -656,6 +773,18 @@ func (al *AgentLoop) runLLMIteration(
|
|||||||
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
|
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
|
||||||
|
|
||||||
|
logger.DebugCF("agent", "LLM response",
|
||||||
|
map[string]any{
|
||||||
|
"agent_id": agent.ID,
|
||||||
|
"iteration": iteration,
|
||||||
|
"content_chars": len(response.Content),
|
||||||
|
"tool_calls": len(response.ToolCalls),
|
||||||
|
"reasoning": response.Reasoning,
|
||||||
|
"target_channel": al.targetReasoningChannelID(opts.Channel),
|
||||||
|
"channel": opts.Channel,
|
||||||
|
})
|
||||||
// Check if no tool calls - we're done
|
// Check if no tool calls - we're done
|
||||||
if len(response.ToolCalls) == 0 {
|
if len(response.ToolCalls) == 0 {
|
||||||
finalContent = response.Content
|
finalContent = response.Content
|
||||||
@@ -690,6 +819,7 @@ func (al *AgentLoop) runLLMIteration(
|
|||||||
assistantMsg := providers.Message{
|
assistantMsg := providers.Message{
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Content: response.Content,
|
Content: response.Content,
|
||||||
|
ReasoningContent: response.ReasoningContent,
|
||||||
}
|
}
|
||||||
for _, tc := range normalizedToolCalls {
|
for _, tc := range normalizedToolCalls {
|
||||||
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
argumentsJSON, _ := json.Marshal(tc.Arguments)
|
||||||
@@ -756,7 +886,7 @@ func (al *AgentLoop) runLLMIteration(
|
|||||||
|
|
||||||
// Send ForUser content to user immediately if not Silent
|
// Send ForUser content to user immediately if not Silent
|
||||||
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
|
||||||
Channel: opts.Channel,
|
Channel: opts.Channel,
|
||||||
ChatID: opts.ChatID,
|
ChatID: opts.ChatID,
|
||||||
Content: toolResult.ForUser,
|
Content: toolResult.ForUser,
|
||||||
@@ -768,6 +898,28 @@ func (al *AgentLoop) runLLMIteration(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If tool returned media refs, publish them as outbound media
|
||||||
|
if len(toolResult.Media) > 0 && opts.SendResponse {
|
||||||
|
parts := make([]bus.MediaPart, 0, len(toolResult.Media))
|
||||||
|
for _, ref := range toolResult.Media {
|
||||||
|
part := bus.MediaPart{Ref: ref}
|
||||||
|
// Populate metadata from MediaStore when available
|
||||||
|
if al.mediaStore != nil {
|
||||||
|
if _, meta, err := al.mediaStore.ResolveWithMeta(ref); err == nil {
|
||||||
|
part.Filename = meta.Filename
|
||||||
|
part.ContentType = meta.ContentType
|
||||||
|
part.Type = inferMediaType(meta.Filename, meta.ContentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts = append(parts, part)
|
||||||
|
}
|
||||||
|
al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{
|
||||||
|
Channel: opts.Channel,
|
||||||
|
ChatID: opts.ChatID,
|
||||||
|
Parts: parts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Determine content for LLM based on tool result
|
// Determine content for LLM based on tool result
|
||||||
contentForLLM := toolResult.ForLLM
|
contentForLLM := toolResult.ForLLM
|
||||||
if contentForLLM == "" && toolResult.Err != nil {
|
if contentForLLM == "" && toolResult.Err != nil {
|
||||||
@@ -820,13 +972,7 @@ func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, c
|
|||||||
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
|
||||||
go func() {
|
go func() {
|
||||||
defer al.summarizing.Delete(summarizeKey)
|
defer al.summarizing.Delete(summarizeKey)
|
||||||
if !constants.IsInternalChannel(channel) {
|
logger.Debug("Memory threshold reached. Optimizing conversation history...")
|
||||||
al.bus.PublishOutbound(bus.OutboundMessage{
|
|
||||||
Channel: channel,
|
|
||||||
ChatID: chatID,
|
|
||||||
Content: "Memory threshold reached. Optimizing conversation history...",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
al.summarizeSession(agent, sessionKey)
|
al.summarizeSession(agent, sessionKey)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -860,7 +1006,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
|
|||||||
droppedCount := mid
|
droppedCount := mid
|
||||||
keptConversation := conversation[mid:]
|
keptConversation := conversation[mid:]
|
||||||
|
|
||||||
newHistory := make([]providers.Message, 0)
|
newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
|
||||||
|
|
||||||
// Append compression note to the original system prompt instead of adding a new system message
|
// Append compression note to the original system prompt instead of adding a new system message
|
||||||
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
|
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
|
||||||
@@ -1024,6 +1170,7 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
|
|||||||
map[string]any{
|
map[string]any{
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1074,6 +1221,7 @@ func (al *AgentLoop) summarizeBatch(
|
|||||||
map[string]any{
|
map[string]any{
|
||||||
"max_tokens": 1024,
|
"max_tokens": 1024,
|
||||||
"temperature": 0.3,
|
"temperature": 0.3,
|
||||||
|
"prompt_cache_key": agent.ID,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1184,21 +1332,20 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractPeer extracts the routing peer from inbound message metadata.
|
// extractPeer extracts the routing peer from the inbound message's structured Peer field.
|
||||||
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
|
||||||
peerKind := msg.Metadata["peer_kind"]
|
if msg.Peer.Kind == "" {
|
||||||
if peerKind == "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
peerID := msg.Metadata["peer_id"]
|
peerID := msg.Peer.ID
|
||||||
if peerID == "" {
|
if peerID == "" {
|
||||||
if peerKind == "direct" {
|
if msg.Peer.Kind == "direct" {
|
||||||
peerID = msg.SenderID
|
peerID = msg.SenderID
|
||||||
} else {
|
} else {
|
||||||
peerID = msg.ChatID
|
peerID = msg.ChatID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return &routing.RoutePeer{Kind: peerKind, ID: peerID}
|
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
|
||||||
|
|||||||
@@ -9,11 +9,23 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sipeed/picoclaw/pkg/bus"
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
"github.com/sipeed/picoclaw/pkg/config"
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
"github.com/sipeed/picoclaw/pkg/providers"
|
"github.com/sipeed/picoclaw/pkg/providers"
|
||||||
"github.com/sipeed/picoclaw/pkg/tools"
|
"github.com/sipeed/picoclaw/pkg/tools"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type fakeChannel struct{ id string }
|
||||||
|
|
||||||
|
func (f *fakeChannel) Name() string { return "fake" }
|
||||||
|
func (f *fakeChannel) Start(ctx context.Context) error { return nil }
|
||||||
|
func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
|
||||||
|
func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }
|
||||||
|
func (f *fakeChannel) IsRunning() bool { return true }
|
||||||
|
func (f *fakeChannel) IsAllowed(string) bool { return true }
|
||||||
|
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
|
||||||
|
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
|
||||||
|
|
||||||
func TestRecordLastChannel(t *testing.T) {
|
func TestRecordLastChannel(t *testing.T) {
|
||||||
// Create temp workspace
|
// Create temp workspace
|
||||||
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
@@ -631,3 +643,158 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
|
|||||||
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(tmpDir)
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
|
||||||
|
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create channel manager: %v", err)
|
||||||
|
}
|
||||||
|
for name, id := range map[string]string{
|
||||||
|
"whatsapp": "rid-whatsapp",
|
||||||
|
"telegram": "rid-telegram",
|
||||||
|
"feishu": "rid-feishu",
|
||||||
|
"discord": "rid-discord",
|
||||||
|
"maixcam": "rid-maixcam",
|
||||||
|
"qq": "rid-qq",
|
||||||
|
"dingtalk": "rid-dingtalk",
|
||||||
|
"slack": "rid-slack",
|
||||||
|
"line": "rid-line",
|
||||||
|
"onebot": "rid-onebot",
|
||||||
|
"wecom": "rid-wecom",
|
||||||
|
"wecom_app": "rid-wecom-app",
|
||||||
|
} {
|
||||||
|
chManager.RegisterChannel(name, &fakeChannel{id: id})
|
||||||
|
}
|
||||||
|
al.SetChannelManager(chManager)
|
||||||
|
tests := []struct {
|
||||||
|
channel string
|
||||||
|
wantID string
|
||||||
|
}{
|
||||||
|
{channel: "whatsapp", wantID: "rid-whatsapp"},
|
||||||
|
{channel: "telegram", wantID: "rid-telegram"},
|
||||||
|
{channel: "feishu", wantID: "rid-feishu"},
|
||||||
|
{channel: "discord", wantID: "rid-discord"},
|
||||||
|
{channel: "maixcam", wantID: "rid-maixcam"},
|
||||||
|
{channel: "qq", wantID: "rid-qq"},
|
||||||
|
{channel: "dingtalk", wantID: "rid-dingtalk"},
|
||||||
|
{channel: "slack", wantID: "rid-slack"},
|
||||||
|
{channel: "line", wantID: "rid-line"},
|
||||||
|
{channel: "onebot", wantID: "rid-onebot"},
|
||||||
|
{channel: "wecom", wantID: "rid-wecom"},
|
||||||
|
{channel: "wecom_app", wantID: "rid-wecom-app"},
|
||||||
|
{channel: "unknown", wantID: ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.channel, func(t *testing.T) {
|
||||||
|
got := al.targetReasoningChannelID(tt.channel)
|
||||||
|
if got != tt.wantID {
|
||||||
|
t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleReasoning(t *testing.T) {
|
||||||
|
newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) {
|
||||||
|
t.Helper()
|
||||||
|
tmpDir, err := os.MkdirTemp("", "agent-test-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp dir: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||||
|
cfg := &config.Config{
|
||||||
|
Agents: config.AgentsConfig{
|
||||||
|
Defaults: config.AgentDefaults{
|
||||||
|
Workspace: tmpDir,
|
||||||
|
Model: "test-model",
|
||||||
|
MaxTokens: 4096,
|
||||||
|
MaxToolIterations: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
msgBus := bus.NewMessageBus()
|
||||||
|
return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("skips when any required field is empty", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
|
||||||
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("publishes one message for non telegram", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected an outbound message")
|
||||||
|
}
|
||||||
|
if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" {
|
||||||
|
t.Fatalf("unexpected outbound message: %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("publishes one message for telegram", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
reasoning := "hello telegram reasoning"
|
||||||
|
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected outbound message")
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.Channel != "telegram" {
|
||||||
|
t.Fatalf("expected telegram channel message, got %+v", msg)
|
||||||
|
}
|
||||||
|
if msg.ChatID != "tg-chat" {
|
||||||
|
t.Fatalf("expected chatID tg-chat, got %+v", msg)
|
||||||
|
}
|
||||||
|
if msg.Content != reasoning {
|
||||||
|
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run("expired ctx", func(t *testing.T) {
|
||||||
|
al, msgBus := newLoop(t)
|
||||||
|
reasoning := "hello telegram reasoning"
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
|
||||||
|
|
||||||
|
ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
msg, ok := msgBus.SubscribeOutbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatalf("expected no outbound message, got %+v", msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
+10
-3
@@ -12,6 +12,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MemoryStore manages persistent memory for the agent.
|
// MemoryStore manages persistent memory for the agent.
|
||||||
@@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
|
|||||||
|
|
||||||
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
||||||
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
||||||
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||||
|
return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadToday reads today's daily note.
|
// ReadToday reads today's daily note.
|
||||||
@@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
|||||||
|
|
||||||
// Ensure month directory exists
|
// Ensure month directory exists
|
||||||
monthDir := filepath.Dir(todayFile)
|
monthDir := filepath.Dir(todayFile)
|
||||||
os.MkdirAll(monthDir, 0o755)
|
if err := os.MkdirAll(monthDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var existingContent string
|
var existingContent string
|
||||||
if data, err := os.ReadFile(todayFile); err == nil {
|
if data, err := os.ReadFile(todayFile); err == nil {
|
||||||
@@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
|||||||
newContent = existingContent + "\n" + content
|
newContent = existingContent + "\n" + content
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(todayFile, []byte(newContent), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecentDailyNotes returns daily notes from the last N days.
|
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||||
|
|||||||
+1
-1
@@ -156,7 +156,7 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
|
|||||||
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
|
||||||
case manualInput := <-manualCh:
|
case manualInput := <-manualCh:
|
||||||
if manualInput == "" {
|
if manualInput == "" {
|
||||||
return nil, fmt.Errorf("manual input cancelled")
|
return nil, fmt.Errorf("manual input canceled")
|
||||||
}
|
}
|
||||||
// Extract code from URL if it's a full URL
|
// Extract code from URL if it's a full URL
|
||||||
code := manualInput
|
code := manualInput
|
||||||
|
|||||||
+5
-6
@@ -5,6 +5,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthCredential struct {
|
type AuthCredential struct {
|
||||||
@@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
|
|||||||
|
|
||||||
func SaveStore(store *AuthStore) error {
|
func SaveStore(store *AuthStore) error {
|
||||||
path := authFilePath()
|
path := authFilePath()
|
||||||
dir := filepath.Dir(path)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(store, "", " ")
|
data, err := json.MarshalIndent(store, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCredential(provider string) (*AuthCredential, error) {
|
func GetCredential(provider string) (*AuthCredential, error) {
|
||||||
|
|||||||
+114
-39
@@ -2,81 +2,156 @@ package bus
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"sync"
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrBusClosed is returned when publishing to a closed MessageBus.
|
||||||
|
var ErrBusClosed = errors.New("message bus closed")
|
||||||
|
|
||||||
|
const defaultBusBufferSize = 64
|
||||||
|
|
||||||
type MessageBus struct {
|
type MessageBus struct {
|
||||||
inbound chan InboundMessage
|
inbound chan InboundMessage
|
||||||
outbound chan OutboundMessage
|
outbound chan OutboundMessage
|
||||||
handlers map[string]MessageHandler
|
outboundMedia chan OutboundMediaMessage
|
||||||
closed bool
|
done chan struct{}
|
||||||
mu sync.RWMutex
|
closed atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMessageBus() *MessageBus {
|
func NewMessageBus() *MessageBus {
|
||||||
return &MessageBus{
|
return &MessageBus{
|
||||||
inbound: make(chan InboundMessage, 100),
|
inbound: make(chan InboundMessage, defaultBusBufferSize),
|
||||||
outbound: make(chan OutboundMessage, 100),
|
outbound: make(chan OutboundMessage, defaultBusBufferSize),
|
||||||
handlers: make(map[string]MessageHandler),
|
outboundMedia: make(chan OutboundMediaMessage, defaultBusBufferSize),
|
||||||
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
|
func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) error {
|
||||||
mb.mu.RLock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.RUnlock()
|
return ErrBusClosed
|
||||||
if mb.closed {
|
}
|
||||||
return
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.inbound <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
mb.inbound <- msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
|
||||||
select {
|
select {
|
||||||
case msg := <-mb.inbound:
|
case msg, ok := <-mb.inbound:
|
||||||
return msg, true
|
return msg, ok
|
||||||
|
case <-mb.done:
|
||||||
|
return InboundMessage{}, false
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return InboundMessage{}, false
|
return InboundMessage{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
|
func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage) error {
|
||||||
mb.mu.RLock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.RUnlock()
|
return ErrBusClosed
|
||||||
if mb.closed {
|
}
|
||||||
return
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.outbound <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
}
|
}
|
||||||
mb.outbound <- msg
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
|
||||||
select {
|
select {
|
||||||
case msg := <-mb.outbound:
|
case msg, ok := <-mb.outbound:
|
||||||
return msg, true
|
return msg, ok
|
||||||
|
case <-mb.done:
|
||||||
|
return OutboundMessage{}, false
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return OutboundMessage{}, false
|
return OutboundMessage{}, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) RegisterHandler(channel string, handler MessageHandler) {
|
func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMediaMessage) error {
|
||||||
mb.mu.Lock()
|
if mb.closed.Load() {
|
||||||
defer mb.mu.Unlock()
|
return ErrBusClosed
|
||||||
mb.handlers[channel] = handler
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case mb.outboundMedia <- msg:
|
||||||
|
return nil
|
||||||
|
case <-mb.done:
|
||||||
|
return ErrBusClosed
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
|
func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMediaMessage, bool) {
|
||||||
mb.mu.RLock()
|
select {
|
||||||
defer mb.mu.RUnlock()
|
case msg, ok := <-mb.outboundMedia:
|
||||||
handler, ok := mb.handlers[channel]
|
return msg, ok
|
||||||
return handler, ok
|
case <-mb.done:
|
||||||
|
return OutboundMediaMessage{}, false
|
||||||
|
case <-ctx.Done():
|
||||||
|
return OutboundMediaMessage{}, false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (mb *MessageBus) Close() {
|
func (mb *MessageBus) Close() {
|
||||||
mb.mu.Lock()
|
if mb.closed.CompareAndSwap(false, true) {
|
||||||
defer mb.mu.Unlock()
|
close(mb.done)
|
||||||
if mb.closed {
|
|
||||||
return
|
// Drain buffered channels so messages aren't silently lost.
|
||||||
|
// Channels are NOT closed to avoid send-on-closed panics from concurrent publishers.
|
||||||
|
drained := 0
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.inbound:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneInbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneInbound:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.outbound:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneOutbound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneOutbound:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-mb.outboundMedia:
|
||||||
|
drained++
|
||||||
|
default:
|
||||||
|
goto doneMedia
|
||||||
|
}
|
||||||
|
}
|
||||||
|
doneMedia:
|
||||||
|
if drained > 0 {
|
||||||
|
logger.DebugCF("bus", "Drained buffered messages during close", map[string]any{
|
||||||
|
"count": drained,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
mb.closed = true
|
|
||||||
close(mb.inbound)
|
|
||||||
close(mb.outbound)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package bus
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublishConsume(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := InboundMessage{
|
||||||
|
Channel: "test",
|
||||||
|
SenderID: "user1",
|
||||||
|
ChatID: "chat1",
|
||||||
|
Content: "hello",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishInbound(ctx, msg); err != nil {
|
||||||
|
t.Fatalf("PublishInbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("ConsumeInbound returned ok=false")
|
||||||
|
}
|
||||||
|
if got.Content != "hello" {
|
||||||
|
t.Fatalf("expected content 'hello', got %q", got.Content)
|
||||||
|
}
|
||||||
|
if got.Channel != "test" {
|
||||||
|
t.Fatalf("expected channel 'test', got %q", got.Channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutboundSubscribe(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
msg := OutboundMessage{
|
||||||
|
Channel: "telegram",
|
||||||
|
ChatID: "123",
|
||||||
|
Content: "world",
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mb.PublishOutbound(ctx, msg); err != nil {
|
||||||
|
t.Fatalf("PublishOutbound failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := mb.SubscribeOutbound(ctx)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("SubscribeOutbound returned ok=false")
|
||||||
|
}
|
||||||
|
if got.Content != "world" {
|
||||||
|
t.Fatalf("expected content 'world', got %q", got.Content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_ContextCancel(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
// Fill the buffer
|
||||||
|
ctx := context.Background()
|
||||||
|
for i := 0; i < defaultBusBufferSize; i++ {
|
||||||
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now buffer is full; publish with a canceled context
|
||||||
|
cancelCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(cancelCtx, InboundMessage{Content: "overflow"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error from canceled context, got nil")
|
||||||
|
}
|
||||||
|
if err != context.Canceled {
|
||||||
|
t.Fatalf("expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishOutbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
err := mb.PublishOutbound(context.Background(), OutboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeInbound_ContextCancel(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
_, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when context is canceled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeInbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, ok := mb.ConsumeInbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSubscribeOutbound_BusClosed(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
_, ok := mb.SubscribeOutbound(ctx)
|
||||||
|
if ok {
|
||||||
|
t.Fatal("expected ok=false when bus is closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConcurrentPublishClose(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
const numGoroutines = 100
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(numGoroutines + 1)
|
||||||
|
|
||||||
|
// Spawn many goroutines trying to publish
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
// Use a short timeout context so we don't block forever after close
|
||||||
|
publishCtx, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
// Errors are expected; we just must not panic or deadlock
|
||||||
|
_ = mb.PublishInbound(publishCtx, InboundMessage{Content: "concurrent"})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close from another goroutine
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
mb.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Must complete without deadlock
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
// success
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("test timed out - possible deadlock")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublishInbound_FullBuffer(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
defer mb.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Fill the buffer
|
||||||
|
for i := 0; i < defaultBusBufferSize; i++ {
|
||||||
|
if err := mb.PublishInbound(ctx, InboundMessage{Content: "fill"}); err != nil {
|
||||||
|
t.Fatalf("fill failed at %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffer is full; publish with short timeout
|
||||||
|
timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := mb.PublishInbound(timeoutCtx, InboundMessage{Content: "overflow"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error when buffer is full and context times out")
|
||||||
|
}
|
||||||
|
if err != context.DeadlineExceeded {
|
||||||
|
t.Fatalf("expected context.DeadlineExceeded, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseIdempotent(t *testing.T) {
|
||||||
|
mb := NewMessageBus()
|
||||||
|
|
||||||
|
// Multiple Close calls must not panic
|
||||||
|
mb.Close()
|
||||||
|
mb.Close()
|
||||||
|
mb.Close()
|
||||||
|
|
||||||
|
// After close, publish should return ErrBusClosed
|
||||||
|
err := mb.PublishInbound(context.Background(), InboundMessage{Content: "test"})
|
||||||
|
if err != ErrBusClosed {
|
||||||
|
t.Fatalf("expected ErrBusClosed after multiple closes, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-1
@@ -1,11 +1,30 @@
|
|||||||
package bus
|
package bus
|
||||||
|
|
||||||
|
// Peer identifies the routing peer for a message (direct, group, channel, etc.)
|
||||||
|
type Peer struct {
|
||||||
|
Kind string `json:"kind"` // "direct" | "group" | "channel" | ""
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SenderInfo provides structured sender identity information.
|
||||||
|
type SenderInfo struct {
|
||||||
|
Platform string `json:"platform,omitempty"` // "telegram", "discord", "slack", ...
|
||||||
|
PlatformID string `json:"platform_id,omitempty"` // raw platform ID, e.g. "123456"
|
||||||
|
CanonicalID string `json:"canonical_id,omitempty"` // "platform:id" format
|
||||||
|
Username string `json:"username,omitempty"` // username (e.g. @alice)
|
||||||
|
DisplayName string `json:"display_name,omitempty"` // display name
|
||||||
|
}
|
||||||
|
|
||||||
type InboundMessage struct {
|
type InboundMessage struct {
|
||||||
Channel string `json:"channel"`
|
Channel string `json:"channel"`
|
||||||
SenderID string `json:"sender_id"`
|
SenderID string `json:"sender_id"`
|
||||||
|
Sender SenderInfo `json:"sender"`
|
||||||
ChatID string `json:"chat_id"`
|
ChatID string `json:"chat_id"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
Media []string `json:"media,omitempty"`
|
Media []string `json:"media,omitempty"`
|
||||||
|
Peer Peer `json:"peer"` // routing peer
|
||||||
|
MessageID string `json:"message_id,omitempty"` // platform message ID
|
||||||
|
MediaScope string `json:"media_scope,omitempty"` // media lifecycle scope
|
||||||
SessionKey string `json:"session_key"`
|
SessionKey string `json:"session_key"`
|
||||||
Metadata map[string]string `json:"metadata,omitempty"`
|
Metadata map[string]string `json:"metadata,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -16,4 +35,18 @@ type OutboundMessage struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type MessageHandler func(InboundMessage) error
|
// MediaPart describes a single media attachment to send.
|
||||||
|
type MediaPart struct {
|
||||||
|
Type string `json:"type"` // "image" | "audio" | "video" | "file"
|
||||||
|
Ref string `json:"ref"` // media store ref, e.g. "media://abc123"
|
||||||
|
Caption string `json:"caption,omitempty"` // optional caption text
|
||||||
|
Filename string `json:"filename,omitempty"` // original filename hint
|
||||||
|
ContentType string `json:"content_type,omitempty"` // MIME type hint
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutboundMediaMessage carries media attachments from Agent to channels via the bus.
|
||||||
|
type OutboundMediaMessage struct {
|
||||||
|
Channel string `json:"channel"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
Parts []MediaPart `json:"parts"`
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user