Merge main into refactor/channel-system

This PR brings in the latest changes from main branch including:
- Cobra-based CLI refactoring
- Model config migration (model -> model_name)
- Enhanced golangci-lint configuration
- Various bug fixes and improvements

Resolved conflicts:
- go.mod: merged pflag and golang.org/x/time dependencies
- pkg/agent/loop.go: kept both logger.Debug and user notification
- cmd/picoclaw/internal/gateway/helpers.go: used channel-manager HTTP setup
- pkg/channels/*: kept refactored media system without channel-side transcription
- pkg/channels/wecom/app.go: preserved both sendMarkdownMessage and handleHealth

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Zhaoyikaiii 2026-02-26 20:52:48 +08:00
commit 0e0b657ff1
128 changed files with 4582 additions and 1264 deletions

2
.gitignore vendored
View file

@ -10,7 +10,7 @@ build/
*.out *.out
/picoclaw /picoclaw
/picoclaw-test /picoclaw-test
cmd/picoclaw/workspace cmd/**/workspace
# Picoclaw specific # Picoclaw specific

View file

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

View file

@ -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
@ -67,6 +68,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:

View file

@ -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
@ -43,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)
@ -92,6 +95,7 @@ build-all: generate
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"
@ -144,6 +148,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
@ -169,7 +177,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"

View file

@ -221,12 +221,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": {
@ -252,6 +253,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)
@ -979,6 +983,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 :

View file

@ -183,12 +183,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": {
@ -221,6 +222,9 @@ picoclaw onboard
} }
``` ```
> **新機能**: `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) - **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)
@ -918,6 +922,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 が自動的にラウンドロビンで分散します:

View file

@ -12,6 +12,9 @@
<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**
@ -219,7 +222,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
@ -229,7 +232,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",
@ -259,6 +263,7 @@ 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**
@ -912,7 +917,8 @@ This design also enables **multi-agent support** with flexible provider selectio
"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
} }
``` ```
@ -1140,7 +1146,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.

View file

@ -222,12 +222,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": {
@ -246,6 +247,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)
@ -973,6 +977,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:

View file

@ -202,12 +202,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": {
@ -220,6 +221,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)
@ -944,6 +948,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:

View file

@ -224,7 +224,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
@ -234,7 +234,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",
@ -263,6 +264,7 @@ picoclaw onboard
``` ```
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。 > **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`PicoClaw 使用默认超时120 秒)。
**3. 获取 API Key** **3. 获取 API Key**
@ -550,7 +552,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
} }
``` ```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 140 KiB

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,4 @@
// PicoClaw - Ultra-lightweight personal AI agent package agent
// License: MIT
package main
import ( import (
"bufio" "bufio"
@ -14,59 +11,40 @@ 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()
@ -86,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,
@ -142,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.Printf("%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 {
@ -177,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)
} }
} }

View file

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

View file

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

View file

@ -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())
} }
func authLogoutCmd() { return nil
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++
}
}
} }
func authLogoutCmd(provider string) error {
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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,16 +1,15 @@
// PicoClaw - Ultra-lightweight personal AI agent package gateway
// License: MIT
package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"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"
@ -38,31 +37,25 @@ import (
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
) )
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()
@ -127,8 +120,7 @@ func gatewayCmd() {
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore) channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
if err != nil { if err != nil {
fmt.Printf("Error creating channel manager: %v\n", err) return fmt.Errorf("error creating channel manager: %w", err)
os.Exit(1)
} }
// Inject channel manager and media store into agent loop // Inject channel manager and media store into agent loop
@ -188,6 +180,9 @@ func gatewayCmd() {
<-sigChan <-sigChan
fmt.Println("\nShutting down...") fmt.Println("\nShutting down...")
if cp, ok := provider.(providers.StatefulProvider); ok {
cp.Close()
}
cancel() cancel()
msgBus.Close() msgBus.Close()
@ -202,6 +197,8 @@ func gatewayCmd() {
cronService.Stop() cronService.Stop()
agentLoop.Stop() agentLoop.Stop()
fmt.Println("✓ Gateway stopped") fmt.Println("✓ Gateway stopped")
return nil
} }
func setupCronTool( func setupCronTool(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(&registry, "registry", "", "Install from registry: --registry <name> <slug>")
return cmd
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 != ""

View file

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

View file

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

View file

@ -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 cmd := &cobra.Command{
goVersion string Use: "picoclaw",
Short: short,
Example: "picoclaw list",
}
cmd.AddCommand(
onboard.NewOnboardCommand(),
agent.NewAgentCommand(),
auth.NewAuthCommand(),
gateway.NewGatewayCommand(),
status.NewStatusCommand(),
cron.NewCronCommand(),
migrate.NewMigrateCommand(),
skills.NewSkillsCommand(),
version.NewVersionCommand(),
) )
const logo = "🦞" return cmd
// 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() (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)
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
})
} }
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)
// get global config directory and builtin skills directory
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())
}

56
cmd/picoclaw/main_test.go Normal file
View file

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

View file

@ -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
@ -217,7 +217,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

View file

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

3
go.mod
View file

@ -15,6 +15,7 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0 github.com/openai/openai-go/v3 v3.22.0
github.com/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
golang.org/x/oauth2 v0.35.0 golang.org/x/oauth2 v0.35.0
@ -22,7 +23,9 @@ require (
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/time v0.14.0 // indirect golang.org/x/time v0.14.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

10
go.sum
View file

@ -25,6 +25,7 @@ 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/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=
@ -72,6 +73,8 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad
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/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=
@ -108,8 +111,14 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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=
@ -151,6 +160,7 @@ github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9dec
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.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=

View file

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

View file

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

209
pkg/agent/context_test.go Normal file
View file

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

View file

@ -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 := ""
@ -133,7 +132,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.

View file

@ -109,10 +109,11 @@ func registerSharedTools(
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())
@ -152,9 +153,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)
} }
} }
@ -580,6 +578,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,
}) })
}, },
) )
@ -596,6 +595,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,
}) })
} }
@ -683,6 +683,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)
@ -835,6 +836,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)
logger.Debug("Memory threshold reached. Optimizing conversation history...")
if !constants.IsInternalChannel(channel) { if !constants.IsInternalChannel(channel) {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel() defer pubCancel()
@ -877,7 +879,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
@ -1041,6 +1043,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 {
@ -1091,6 +1094,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 {

View file

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

View file

@ -172,7 +172,10 @@ func (c *OneBotChannel) connect() error {
header["Authorization"] = []string{"Bearer " + c.config.AccessToken} header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
} }
conn, _, err := dialer.Dial(c.config.WSUrl, header) conn, resp, err := dialer.Dial(c.config.WSUrl, header)
if resp != nil {
resp.Body.Close()
}
if err != nil { if err != nil {
return err return err
} }
@ -313,7 +316,7 @@ func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.D
case <-time.After(timeout): case <-time.After(timeout):
return nil, fmt.Errorf("API request %s timed out after %v", action, timeout) return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
case <-c.ctx.Done(): case <-c.ctx.Done():
return nil, fmt.Errorf("context cancelled") return nil, fmt.Errorf("context canceled")
} }
} }
@ -815,7 +818,6 @@ func (c *OneBotChannel) parseMessageSegments(
textParts = append(textParts, "[forward message]") textParts = append(textParts, "[forward message]")
default: default:
} }
} }

View file

@ -539,5 +539,5 @@ func parseSlackChatID(chatID string) (channelID, threadTS string) {
if len(parts) > 1 { if len(parts) > 1 {
threadTS = parts[1] threadTS = parts[1]
} }
return return channelID, threadTS
} }

View file

@ -25,6 +25,19 @@ import (
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
var (
reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
reBoldUnder = regexp.MustCompile(`__(.+?)__`)
reItalic = regexp.MustCompile(`_([^_]+)_`)
reStrike = regexp.MustCompile(`~~(.+?)~~`)
reListItem = regexp.MustCompile(`^[-*]\s+`)
reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
reInlineCode = regexp.MustCompile("`([^`]+)`")
)
type TelegramChannel struct { type TelegramChannel struct {
*channels.BaseChannel *channels.BaseChannel
bot *telego.Bot bot *telego.Bot
@ -522,19 +535,18 @@ func markdownToTelegramHTML(text string) string {
inlineCodes := extractInlineCodes(text) inlineCodes := extractInlineCodes(text)
text = inlineCodes.text text = inlineCodes.text
text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1") text = reHeading.ReplaceAllString(text, "$1")
text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1") text = reBlockquote.ReplaceAllString(text, "$1")
text = escapeHTML(text) text = escapeHTML(text)
text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `<a href="$2">$1</a>`) text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "<b>$1</b>") text = reBoldStar.ReplaceAllString(text, "<b>$1</b>")
text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "<b>$1</b>") text = reBoldUnder.ReplaceAllString(text, "<b>$1</b>")
reItalic := regexp.MustCompile(`_([^_]+)_`)
text = reItalic.ReplaceAllStringFunc(text, func(s string) string { text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
match := reItalic.FindStringSubmatch(s) match := reItalic.FindStringSubmatch(s)
if len(match) < 2 { if len(match) < 2 {
@ -543,9 +555,9 @@ func markdownToTelegramHTML(text string) string {
return "<i>" + match[1] + "</i>" return "<i>" + match[1] + "</i>"
}) })
text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "<s>$1</s>") text = reStrike.ReplaceAllString(text, "<s>$1</s>")
text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ") text = reListItem.ReplaceAllString(text, "• ")
for i, code := range inlineCodes.codes { for i, code := range inlineCodes.codes {
escaped := escapeHTML(code) escaped := escapeHTML(code)
@ -570,8 +582,7 @@ type codeBlockMatch struct {
} }
func extractCodeBlocks(text string) codeBlockMatch { func extractCodeBlocks(text string) codeBlockMatch {
re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") matches := reCodeBlock.FindAllStringSubmatch(text, -1)
matches := re.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches)) codes := make([]string, 0, len(matches))
for _, match := range matches { for _, match := range matches {
@ -579,7 +590,7 @@ func extractCodeBlocks(text string) codeBlockMatch {
} }
i := 0 i := 0
text = re.ReplaceAllStringFunc(text, func(m string) string { text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00CB%d\x00", i) placeholder := fmt.Sprintf("\x00CB%d\x00", i)
i++ i++
return placeholder return placeholder
@ -594,8 +605,7 @@ type inlineCodeMatch struct {
} }
func extractInlineCodes(text string) inlineCodeMatch { func extractInlineCodes(text string) inlineCodeMatch {
re := regexp.MustCompile("`([^`]+)`") matches := reInlineCode.FindAllStringSubmatch(text, -1)
matches := re.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches)) codes := make([]string, 0, len(matches))
for _, match := range matches { for _, match := range matches {
@ -603,7 +613,7 @@ func extractInlineCodes(text string) inlineCodeMatch {
} }
i := 0 i := 0
text = re.ReplaceAllStringFunc(text, func(m string) string { text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00IC%d\x00", i) placeholder := fmt.Sprintf("\x00IC%d\x00", i)
i++ i++
return placeholder return placeholder

View file

@ -81,7 +81,7 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error {
switch args { switch args {
case "model": case "model":
response = fmt.Sprintf("Current Model: %s (Provider: %s)", response = fmt.Sprintf("Current Model: %s (Provider: %s)",
c.config.Agents.Defaults.Model, c.config.Agents.Defaults.GetModelName(),
c.config.Agents.Defaults.Provider) c.config.Agents.Defaults.Provider)
case "channel": case "channel":
response = "Current Channel: telegram" response = "Current Channel: telegram"
@ -120,7 +120,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error {
provider = "configured default" provider = "configured default"
} }
response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
c.config.Agents.Defaults.Model, provider) c.config.Agents.Defaults.GetModelName(), provider)
case "channels": case "channels":
var enabled []string var enabled []string

View file

@ -49,7 +49,10 @@ func (c *WhatsAppChannel) Start(ctx context.Context) error {
dialer := websocket.DefaultDialer dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second dialer.HandshakeTimeout = 10 * time.Second
conn, _, err := dialer.Dial(c.url, nil) conn, resp, err := dialer.Dial(c.url, nil)
if resp != nil {
resp.Body.Close()
}
if err != nil { if err != nil {
c.cancel() c.cancel()
return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err) return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err)

View file

@ -170,7 +170,8 @@ type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"` ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
@ -179,6 +180,15 @@ type AgentDefaults struct {
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
} }
// GetModelName returns the effective model name for the agent defaults.
// It prefers the new "model_name" field but falls back to "model" for backward compatibility.
func (d *AgentDefaults) GetModelName() string {
if d.ModelName != "" {
return d.ModelName
}
return d.Model
}
type ChannelsConfig struct { type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"` WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"` Telegram TelegramConfig `json:"telegram"`
@ -414,6 +424,7 @@ type ProviderConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"` Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc` ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
} }
@ -446,6 +457,7 @@ type ModelConfig struct {
// Optional optimizations // Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens") MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
RequestTimeout int `json:"request_timeout,omitempty"`
} }
// Validate checks if the ModelConfig has all required fields. // Validate checks if the ModelConfig has all required fields.
@ -493,6 +505,9 @@ type WebToolsConfig struct {
Tavily TavilyConfig `json:"tavily"` Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"` Perplexity PerplexityConfig `json:"perplexity"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
} }
type CronToolsConfig struct { type CronToolsConfig struct {
@ -549,6 +564,20 @@ func LoadConfig(path string) (*Config, error) {
return nil, err return nil, err
} }
// Pre-scan the JSON to check how many model_list entries the user provided.
// Go's JSON decoder reuses existing slice backing-array elements rather than
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
// would silently inherit values from the DefaultConfig template at the same
// index position. We only reset cfg.ModelList when the user actually provides
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
var tmp Config
if err := json.Unmarshal(data, &tmp); err != nil {
return nil, err
}
if len(tmp.ModelList) > 0 {
cfg.ModelList = nil
}
if err := json.Unmarshal(data, cfg); err != nil { if err := json.Unmarshal(data, cfg); err != nil {
return nil, err return nil, err
} }

View file

@ -392,3 +392,33 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
t.Fatal("OpenAI codex web search should be false when disabled in config file") t.Fatal("OpenAI codex web search should be false when disabled in config file")
} }
} }
func TestLoadConfig_WebToolsProxy(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
configJSON := `{
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
}`
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
t.Fatalf("os.WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if cfg.Tools.Web.Proxy != "http://127.0.0.1:7890" {
t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890")
}
}
// TestDefaultConfig_DMScope verifies the default dm_scope value
func TestDefaultConfig_DMScope(t *testing.T) {
cfg := DefaultConfig()
if cfg.Session.DMScope != "per-channel-peer" {
t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope)
}
}

View file

@ -21,7 +21,7 @@ func DefaultConfig() *Config {
}, },
Bindings: []AgentBinding{}, Bindings: []AgentBinding{},
Session: SessionConfig{ Session: SessionConfig{
DMScope: "main", DMScope: "per-channel-peer",
}, },
Channels: ChannelsConfig{ Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{ WhatsApp: WhatsAppConfig{
@ -292,6 +292,7 @@ func DefaultConfig() *Config {
}, },
Tools: ToolsConfig{ Tools: ToolsConfig{
Web: WebToolsConfig{ Web: WebToolsConfig{
Proxy: "",
Brave: BraveConfig{ Brave: BraveConfig{
Enabled: false, Enabled: false,
APIKey: "", APIKey: "",

View file

@ -41,7 +41,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
// Get user's configured provider and model // Get user's configured provider and model
userProvider := strings.ToLower(cfg.Agents.Defaults.Provider) userProvider := strings.ToLower(cfg.Agents.Defaults.Provider)
userModel := cfg.Agents.Defaults.Model userModel := cfg.Agents.Defaults.GetModelName()
p := cfg.Providers p := cfg.Providers
@ -65,6 +65,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.OpenAI.APIKey, APIKey: p.OpenAI.APIKey,
APIBase: p.OpenAI.APIBase, APIBase: p.OpenAI.APIBase,
Proxy: p.OpenAI.Proxy, Proxy: p.OpenAI.Proxy,
RequestTimeout: p.OpenAI.RequestTimeout,
AuthMethod: p.OpenAI.AuthMethod, AuthMethod: p.OpenAI.AuthMethod,
}, true }, true
}, },
@ -82,6 +83,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Anthropic.APIKey, APIKey: p.Anthropic.APIKey,
APIBase: p.Anthropic.APIBase, APIBase: p.Anthropic.APIBase,
Proxy: p.Anthropic.Proxy, Proxy: p.Anthropic.Proxy,
RequestTimeout: p.Anthropic.RequestTimeout,
AuthMethod: p.Anthropic.AuthMethod, AuthMethod: p.Anthropic.AuthMethod,
}, true }, true
}, },
@ -99,6 +101,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.OpenRouter.APIKey, APIKey: p.OpenRouter.APIKey,
APIBase: p.OpenRouter.APIBase, APIBase: p.OpenRouter.APIBase,
Proxy: p.OpenRouter.Proxy, Proxy: p.OpenRouter.Proxy,
RequestTimeout: p.OpenRouter.RequestTimeout,
}, true }, true
}, },
}, },
@ -115,6 +118,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Groq.APIKey, APIKey: p.Groq.APIKey,
APIBase: p.Groq.APIBase, APIBase: p.Groq.APIBase,
Proxy: p.Groq.Proxy, Proxy: p.Groq.Proxy,
RequestTimeout: p.Groq.RequestTimeout,
}, true }, true
}, },
}, },
@ -131,6 +135,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Zhipu.APIKey, APIKey: p.Zhipu.APIKey,
APIBase: p.Zhipu.APIBase, APIBase: p.Zhipu.APIBase,
Proxy: p.Zhipu.Proxy, Proxy: p.Zhipu.Proxy,
RequestTimeout: p.Zhipu.RequestTimeout,
}, true }, true
}, },
}, },
@ -147,6 +152,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.VLLM.APIKey, APIKey: p.VLLM.APIKey,
APIBase: p.VLLM.APIBase, APIBase: p.VLLM.APIBase,
Proxy: p.VLLM.Proxy, Proxy: p.VLLM.Proxy,
RequestTimeout: p.VLLM.RequestTimeout,
}, true }, true
}, },
}, },
@ -163,6 +169,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Gemini.APIKey, APIKey: p.Gemini.APIKey,
APIBase: p.Gemini.APIBase, APIBase: p.Gemini.APIBase,
Proxy: p.Gemini.Proxy, Proxy: p.Gemini.Proxy,
RequestTimeout: p.Gemini.RequestTimeout,
}, true }, true
}, },
}, },
@ -179,6 +186,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Nvidia.APIKey, APIKey: p.Nvidia.APIKey,
APIBase: p.Nvidia.APIBase, APIBase: p.Nvidia.APIBase,
Proxy: p.Nvidia.Proxy, Proxy: p.Nvidia.Proxy,
RequestTimeout: p.Nvidia.RequestTimeout,
}, true }, true
}, },
}, },
@ -195,6 +203,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Ollama.APIKey, APIKey: p.Ollama.APIKey,
APIBase: p.Ollama.APIBase, APIBase: p.Ollama.APIBase,
Proxy: p.Ollama.Proxy, Proxy: p.Ollama.Proxy,
RequestTimeout: p.Ollama.RequestTimeout,
}, true }, true
}, },
}, },
@ -211,6 +220,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Moonshot.APIKey, APIKey: p.Moonshot.APIKey,
APIBase: p.Moonshot.APIBase, APIBase: p.Moonshot.APIBase,
Proxy: p.Moonshot.Proxy, Proxy: p.Moonshot.Proxy,
RequestTimeout: p.Moonshot.RequestTimeout,
}, true }, true
}, },
}, },
@ -227,6 +237,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.ShengSuanYun.APIKey, APIKey: p.ShengSuanYun.APIKey,
APIBase: p.ShengSuanYun.APIBase, APIBase: p.ShengSuanYun.APIBase,
Proxy: p.ShengSuanYun.Proxy, Proxy: p.ShengSuanYun.Proxy,
RequestTimeout: p.ShengSuanYun.RequestTimeout,
}, true }, true
}, },
}, },
@ -243,6 +254,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.DeepSeek.APIKey, APIKey: p.DeepSeek.APIKey,
APIBase: p.DeepSeek.APIBase, APIBase: p.DeepSeek.APIBase,
Proxy: p.DeepSeek.Proxy, Proxy: p.DeepSeek.Proxy,
RequestTimeout: p.DeepSeek.RequestTimeout,
}, true }, true
}, },
}, },
@ -259,6 +271,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Cerebras.APIKey, APIKey: p.Cerebras.APIKey,
APIBase: p.Cerebras.APIBase, APIBase: p.Cerebras.APIBase,
Proxy: p.Cerebras.Proxy, Proxy: p.Cerebras.Proxy,
RequestTimeout: p.Cerebras.RequestTimeout,
}, true }, true
}, },
}, },
@ -275,6 +288,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.VolcEngine.APIKey, APIKey: p.VolcEngine.APIKey,
APIBase: p.VolcEngine.APIBase, APIBase: p.VolcEngine.APIBase,
Proxy: p.VolcEngine.Proxy, Proxy: p.VolcEngine.Proxy,
RequestTimeout: p.VolcEngine.RequestTimeout,
}, true }, true
}, },
}, },
@ -321,6 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Qwen.APIKey, APIKey: p.Qwen.APIKey,
APIBase: p.Qwen.APIBase, APIBase: p.Qwen.APIBase,
Proxy: p.Qwen.Proxy, Proxy: p.Qwen.Proxy,
RequestTimeout: p.Qwen.RequestTimeout,
}, true }, true
}, },
}, },
@ -337,6 +352,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
APIKey: p.Mistral.APIKey, APIKey: p.Mistral.APIKey,
APIBase: p.Mistral.APIBase, APIBase: p.Mistral.APIBase,
Proxy: p.Mistral.Proxy, Proxy: p.Mistral.Proxy,
RequestTimeout: p.Mistral.RequestTimeout,
}, true }, true
}, },
}, },

View file

@ -166,6 +166,27 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
} }
} }
func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
Ollama: ProviderConfig{
APIKey: "ollama-key",
RequestTimeout: 300,
},
},
}
result := ConvertProvidersToModelList(cfg)
if len(result) != 1 {
t.Fatalf("len(result) = %d, want 1", len(result))
}
if result[0].RequestTimeout != 300 {
t.Errorf("RequestTimeout = %d, want %d", result[0].RequestTimeout, 300)
}
}
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) { func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
cfg := &Config{ cfg := &Config{
Providers: ProvidersConfig{ Providers: ProvidersConfig{

View file

@ -6,6 +6,7 @@
package config package config
import ( import (
"encoding/json"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@ -114,6 +115,137 @@ func TestGetModelConfig_Concurrent(t *testing.T) {
} }
} }
func TestAgentDefaults_GetModelName_BackwardCompat(t *testing.T) {
tests := []struct {
name string
defaults AgentDefaults
wantName string
}{
{
name: "new model_name field only",
defaults: AgentDefaults{ModelName: "new-model"},
wantName: "new-model",
},
{
name: "old model field only",
defaults: AgentDefaults{Model: "legacy-model"},
wantName: "legacy-model",
},
{
name: "both fields - model_name takes precedence",
defaults: AgentDefaults{ModelName: "new-model", Model: "old-model"},
wantName: "new-model",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.defaults.GetModelName(); got != tt.wantName {
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
}
})
}
}
func TestAgentDefaults_JSON_BackwardCompat(t *testing.T) {
tests := []struct {
name string
json string
wantName string
}{
{
name: "new model_name field",
json: `{"model_name": "gpt4"}`,
wantName: "gpt4",
},
{
name: "old model field",
json: `{"model": "gpt4"}`,
wantName: "gpt4",
},
{
name: "both fields - model_name wins",
json: `{"model_name": "new", "model": "old"}`,
wantName: "new",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var defaults AgentDefaults
if err := json.Unmarshal([]byte(tt.json), &defaults); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
if got := defaults.GetModelName(); got != tt.wantName {
t.Errorf("GetModelName() = %q, want %q", got, tt.wantName)
}
})
}
}
func TestFullConfig_JSON_BackwardCompat(t *testing.T) {
// Test complete config with both old and new formats
oldFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
newFormat := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt4",
"max_tokens": 4096
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-4o",
"api_key": "test-key"
}
]
}`
for name, jsonStr := range map[string]string{
"old format (model)": oldFormat,
"new format (model_name)": newFormat,
} {
t.Run(name, func(t *testing.T) {
cfg := &Config{}
if err := json.Unmarshal([]byte(jsonStr), cfg); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}
// Check that GetModelName returns correct value
if got := cfg.Agents.Defaults.GetModelName(); got != "gpt4" {
t.Errorf("GetModelName() = %q, want %q", got, "gpt4")
}
// Check that GetModelConfig works
modelCfg, err := cfg.GetModelConfig("gpt4")
if err != nil {
t.Fatalf("GetModelConfig error: %v", err)
}
if modelCfg.Model != "openai/gpt-4o" {
t.Errorf("Model = %q, want %q", modelCfg.Model, "openai/gpt-4o")
}
})
}
}
func TestModelConfig_Validate(t *testing.T) { func TestModelConfig_Validate(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@ -233,3 +365,38 @@ func TestConfig_ValidateModelList(t *testing.T) {
}) })
} }
} }
func TestModelConfig_RequestTimeoutParsing(t *testing.T) {
jsonData := `{
"model_name": "slow-local",
"model": "openai/local-model",
"api_base": "http://localhost:11434/v1",
"request_timeout": 300
}`
var cfg ModelConfig
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if cfg.RequestTimeout != 300 {
t.Fatalf("RequestTimeout = %d, want 300", cfg.RequestTimeout)
}
}
func TestModelConfig_RequestTimeoutDefaultZeroValue(t *testing.T) {
jsonData := `{
"model_name": "default-timeout",
"model": "openai/gpt-4o",
"api_key": "test-key"
}`
var cfg ModelConfig
if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
t.Fatalf("Unmarshal() error = %v", err)
}
if cfg.RequestTimeout != 0 {
t.Fatalf("RequestTimeout = %d, want 0", cfg.RequestTimeout)
}
}

View file

@ -36,7 +36,6 @@ var usbClassToCapability = map[string]string{
type USBMonitor struct { type USBMonitor struct {
cmd *exec.Cmd cmd *exec.Cmd
cancel context.CancelFunc
mu sync.Mutex mu sync.Mutex
} }

View file

@ -167,7 +167,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
} }
if handler == nil { if handler == nil {
hs.logError("Heartbeat handler not configured") hs.logErrorf("Heartbeat handler not configured")
return return
} }
@ -176,23 +176,23 @@ func (hs *HeartbeatService) executeHeartbeat() {
channel, chatID := hs.parseLastChannel(lastChannel) channel, chatID := hs.parseLastChannel(lastChannel)
// Debug log for channel resolution // Debug log for channel resolution
hs.logInfo("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel) hs.logInfof("Resolved channel: %s, chatID: %s (from lastChannel: %s)", channel, chatID, lastChannel)
result := handler(prompt, channel, chatID) result := handler(prompt, channel, chatID)
if result == nil { if result == nil {
hs.logInfo("Heartbeat handler returned nil result") hs.logInfof("Heartbeat handler returned nil result")
return return
} }
// Handle different result types // Handle different result types
if result.IsError { if result.IsError {
hs.logError("Heartbeat error: %s", result.ForLLM) hs.logErrorf("Heartbeat error: %s", result.ForLLM)
return return
} }
if result.Async { if result.Async {
hs.logInfo("Async task started: %s", result.ForLLM) hs.logInfof("Async task started: %s", result.ForLLM)
logger.InfoCF("heartbeat", "Async heartbeat task started", logger.InfoCF("heartbeat", "Async heartbeat task started",
map[string]any{ map[string]any{
"message": result.ForLLM, "message": result.ForLLM,
@ -202,7 +202,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
// Check if silent // Check if silent
if result.Silent { if result.Silent {
hs.logInfo("Heartbeat OK - silent") hs.logInfof("Heartbeat OK - silent")
return return
} }
@ -213,7 +213,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
hs.sendResponse(result.ForLLM) hs.sendResponse(result.ForLLM)
} }
hs.logInfo("Heartbeat completed: %s", result.ForLLM) hs.logInfof("Heartbeat completed: %s", result.ForLLM)
} }
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md // buildPrompt builds the heartbeat prompt from HEARTBEAT.md
@ -226,7 +226,7 @@ func (hs *HeartbeatService) buildPrompt() string {
hs.createDefaultHeartbeatTemplate() hs.createDefaultHeartbeatTemplate()
return "" return ""
} }
hs.logError("Error reading HEARTBEAT.md: %v", err) hs.logErrorf("Error reading HEARTBEAT.md: %v", err)
return "" return ""
} }
@ -277,9 +277,9 @@ Add your heartbeat tasks below this line:
` `
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil { if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
hs.logError("Failed to create default HEARTBEAT.md: %v", err) hs.logErrorf("Failed to create default HEARTBEAT.md: %v", err)
} else { } else {
hs.logInfo("Created default HEARTBEAT.md template") hs.logInfof("Created default HEARTBEAT.md template")
} }
} }
@ -290,14 +290,14 @@ func (hs *HeartbeatService) sendResponse(response string) {
hs.mu.RUnlock() hs.mu.RUnlock()
if msgBus == nil { if msgBus == nil {
hs.logInfo("No message bus configured, heartbeat result not sent") hs.logInfof("No message bus configured, heartbeat result not sent")
return return
} }
// Get last channel from state // Get last channel from state
lastChannel := hs.state.GetLastChannel() lastChannel := hs.state.GetLastChannel()
if lastChannel == "" { if lastChannel == "" {
hs.logInfo("No last channel recorded, heartbeat result not sent") hs.logInfof("No last channel recorded, heartbeat result not sent")
return return
} }
@ -316,7 +316,7 @@ func (hs *HeartbeatService) sendResponse(response string) {
Content: response, Content: response,
}) })
hs.logInfo("Heartbeat result sent to %s", platform) hs.logInfof("Heartbeat result sent to %s", platform)
} }
// parseLastChannel parses the last channel string into platform and userID. // parseLastChannel parses the last channel string into platform and userID.
@ -329,7 +329,7 @@ func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, user
// Parse channel format: "platform:user_id" (e.g., "telegram:123456") // Parse channel format: "platform:user_id" (e.g., "telegram:123456")
parts := strings.SplitN(lastChannel, ":", 2) parts := strings.SplitN(lastChannel, ":", 2)
if len(parts) != 2 || parts[0] == "" || parts[1] == "" { if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
hs.logError("Invalid last channel format: %s", lastChannel) hs.logErrorf("Invalid last channel format: %s", lastChannel)
return "", "" return "", ""
} }
@ -337,25 +337,25 @@ func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, user
// Skip internal channels // Skip internal channels
if constants.IsInternalChannel(platform) { if constants.IsInternalChannel(platform) {
hs.logInfo("Skipping internal channel: %s", platform) hs.logInfof("Skipping internal channel: %s", platform)
return "", "" return "", ""
} }
return platform, userID return platform, userID
} }
// logInfo logs an informational message to the heartbeat log // logInfof logs an informational message to the heartbeat log
func (hs *HeartbeatService) logInfo(format string, args ...any) { func (hs *HeartbeatService) logInfof(format string, args ...any) {
hs.log("INFO", format, args...) hs.logf("INFO", format, args...)
} }
// logError logs an error message to the heartbeat log // logErrorf logs an error message to the heartbeat log
func (hs *HeartbeatService) logError(format string, args ...any) { func (hs *HeartbeatService) logErrorf(format string, args ...any) {
hs.log("ERROR", format, args...) hs.logf("ERROR", format, args...)
} }
// log writes a message to the heartbeat log file // logf writes a message to the heartbeat log file
func (hs *HeartbeatService) log(level, format string, args ...any) { func (hs *HeartbeatService) logf(level, format string, args ...any) {
logFile := filepath.Join(hs.workspace, "heartbeat.log") logFile := filepath.Join(hs.workspace, "heartbeat.log")
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil { if err != nil {

View file

@ -191,7 +191,7 @@ func TestLogPath(t *testing.T) {
hs := NewHeartbeatService(tmpDir, 30, true) hs := NewHeartbeatService(tmpDir, 30, true)
// Write a log entry // Write a log entry
hs.log("INFO", "Test log entry") hs.logf("INFO", "Test log entry")
// Verify log file exists at workspace root // Verify log file exists at workspace root
expectedLogPath := filepath.Join(tmpDir, "heartbeat.log") expectedLogPath := filepath.Join(tmpDir, "heartbeat.log")

View file

@ -153,7 +153,7 @@ func formatComponent(component string) string {
} }
func formatFields(fields map[string]any) string { func formatFields(fields map[string]any) string {
var parts []string parts := make([]string, 0, len(fields))
for k, v := range fields { for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v)) parts = append(parts, fmt.Sprintf("%s=%v", k, v))
} }

View file

@ -73,7 +73,10 @@ func ConvertConfig(data map[string]any) (*config.Config, []string, error) {
if agents, ok := getMap(data, "agents"); ok { if agents, ok := getMap(data, "agents"); ok {
if defaults, ok := getMap(agents, "defaults"); ok { if defaults, ok := getMap(agents, "defaults"); ok {
if v, ok := getString(defaults, "model"); ok { // Prefer model_name, fallback to model for backward compatibility
if v, ok := getString(defaults, "model_name"); ok {
cfg.Agents.Defaults.ModelName = v
} else if v, ok := getString(defaults, "model"); ok {
cfg.Agents.Defaults.Model = v cfg.Agents.Defaults.Model = v
} }
if v, ok := getFloat(defaults, "max_tokens"); ok { if v, ok := getFloat(defaults, "max_tokens"); ok {

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