Merge branch 'main' into t3

This commit is contained in:
Cytown 2026-03-30 18:01:07 +08:00
commit 50b8d9bf83
66 changed files with 3895 additions and 1617 deletions

View file

@ -130,14 +130,17 @@ build: generate
build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@if [ ! -f web/backend/dist/index.html ]; then \
echo "Building frontend..."; \
cd web/frontend && pnpm install && pnpm build:backend; \
fi
@$(WEB_GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
@$(MAKE) -C web build \
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \
WEB_GO='$(WEB_GO)' \
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
LDFLAGS='$(LDFLAGS)'
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
build-launcher-frontend:
@$(MAKE) -C web build-frontend
## build-launcher-tui: Build the picoclaw-launcher TUI binary
build-launcher-tui:
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."

View file

@ -462,6 +462,8 @@ Parlez à votre PicoClaw via plus de 17 plateformes de messagerie :
> Tous les channels basés sur webhook partagent un seul serveur HTTP Gateway (`gateway.host`:`gateway.port`, par défaut `127.0.0.1:18790`). Feishu utilise le mode WebSocket/SDK et n'utilise pas le serveur HTTP partagé.
> La verbosité des logs est contrôlée par `gateway.log_level` (par défaut : `warn`). Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`. Peut aussi être défini via `PICOCLAW_LOG_LEVEL`. Voir [Configuration](docs/fr/configuration.md#niveau-de-log-du-gateway) pour plus de détails.
Pour les instructions détaillées de configuration des channels, voir [Configuration des applications de chat](docs/fr/chat-apps.md).
## 🔧 Outils

View file

@ -458,6 +458,8 @@ Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk detail.
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
## 🔧 Tools

View file

@ -458,6 +458,8 @@ Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](docs/configuration.md#gateway-log-level) per i dettagli.
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
## 🔧 Strumenti

View file

@ -458,6 +458,8 @@ Provider の完全な設定詳細は [Provider とモデル](docs/ja/providers.m
> webhook ベースのすべての Channel は単一の Gateway HTTP サーバー(`gateway.host`:`gateway.port`、デフォルト `127.0.0.1:18790`を共有します。Feishu は WebSocket/SDK モードを使用し、共有 HTTP サーバーを使用しません。
> ログの詳細度は `gateway.log_level` で制御します(デフォルト:`warn`)。サポートされる値:`debug``info``warn``error``fatal``PICOCLAW_LOG_LEVEL` 環境変数でも設定可能です。詳細は[設定ガイド](docs/ja/configuration.md#gateway-ログレベル)を参照してください。
Channel の詳細なセットアップ手順は [チャットアプリ設定](docs/ja/chat-apps.md) を参照してください。
## 🔧 ツール

View file

@ -464,6 +464,8 @@ Talk to your PicoClaw through 17+ messaging platforms:
> All webhook-based channels share a single Gateway HTTP server (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu uses WebSocket/SDK mode and does not use the shared HTTP server.
> Log verbosity is controlled by `gateway.log_level` (default: `warn`). Supported values: `debug`, `info`, `warn`, `error`, `fatal`. Can also be set via `PICOCLAW_LOG_LEVEL`. See [Configuration](docs/configuration.md#gateway-log-level) for details.
For detailed channel setup instructions, see [Chat Apps Configuration](docs/chat-apps.md).
## 🔧 Tools

View file

@ -458,6 +458,8 @@ Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan:
> Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi.
> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk butiran.
Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md).
## 🔧 Alat

View file

@ -458,6 +458,8 @@ Converse com seu PicoClaw por meio de mais de 17 plataformas de mensagens:
> Todos os channels baseados em webhook compartilham um único servidor HTTP do Gateway (`gateway.host`:`gateway.port`, padrão `127.0.0.1:18790`). O Feishu usa modo WebSocket/SDK e não utiliza o servidor HTTP compartilhado.
> A verbosidade dos logs é controlada por `gateway.log_level` (padrão: `warn`). Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`. Também pode ser definido via `PICOCLAW_LOG_LEVEL`. Veja [Configuração](docs/pt-br/configuration.md#nível-de-log-do-gateway) para detalhes.
Para instruções detalhadas de configuração de channels, veja [Configuração de Apps de Chat](docs/pt-br/chat-apps.md).
## 🔧 Ferramentas

View file

@ -458,6 +458,8 @@ Trò chuyện với PicoClaw của bạn qua 17+ nền tảng nhắn tin:
> Tất cả các Channel dựa trên webhook dùng chung một Gateway HTTP server (`gateway.host`:`gateway.port`, mặc định `127.0.0.1:18790`). Feishu sử dụng chế độ WebSocket/SDK và không dùng HTTP server chung.
> Mức độ chi tiết log được kiểm soát bởi `gateway.log_level` (mặc định: `warn`). Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`. Cũng có thể đặt qua `PICOCLAW_LOG_LEVEL`. Xem [Cấu hình](docs/vi/configuration.md#mức-log-của-gateway) để biết thêm chi tiết.
Để biết hướng dẫn thiết lập Channel chi tiết, xem [Cấu hình Ứng dụng Chat](docs/vi/chat-apps.md).
## 🔧 Tools

View file

@ -458,6 +458,8 @@ PicoClaw 通过 `model_list` 配置支持 30+ LLM Provider使用 `协议/模
> 所有基于 Webhook 的 Channel 共用同一个 Gateway HTTP 服务器(`gateway.host`:`gateway.port`,默认 `127.0.0.1:18790`)。飞书使用 WebSocket/SDK 模式,不使用共享 HTTP 服务器。
> 日志详细程度通过 `gateway.log_level` 控制(默认:`warn`)。支持的值:`debug``info``warn``error``fatal`。也可通过 `PICOCLAW_LOG_LEVEL` 环境变量设置。详见[配置指南](docs/zh/configuration.md#gateway-日志等级)。
详细 Channel 配置说明请参阅 [聊天应用配置](docs/zh/chat-apps.md)。
## 🔧 Tools

View file

@ -14,7 +14,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
message string
every int64
cronExp string
deliver bool
channel string
to string
)
@ -37,7 +36,7 @@ func newAddCommand(storePath func() string) *cobra.Command {
}
cs := cron.NewCronService(storePath(), nil)
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
job, err := cs.AddJob(name, schedule, message, channel, to)
if err != nil {
return fmt.Errorf("error adding job: %w", err)
}
@ -52,7 +51,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
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")

View file

@ -21,7 +21,6 @@ func TestNewAddSubcommand(t *testing.T) {
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"))

View file

@ -81,7 +81,7 @@ func listAvailableModels(cfg *config.Config) {
if model.ModelName == defaultModel {
marker = "> "
}
if model.APIKey() == "" {
if !model.Enabled {
continue
}
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
@ -92,7 +92,7 @@ func setDefaultModel(configPath string, cfg *config.Config, modelName string) er
// Validate that the model exists in model_list
modelFound := false
for _, model := range cfg.ModelList {
if model.APIKey() != "" && model.ModelName == modelName {
if model.Enabled && model.ModelName == modelName {
modelFound = true
break
}

View file

@ -65,11 +65,17 @@ func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
},
},
ModelList: []*config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
{
ModelName: "gpt-4",
Model: "openai/gpt-4",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "claude-3",
Model: "anthropic/claude-3",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
@ -92,7 +98,12 @@ func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
},
},
ModelList: []*config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
{
ModelName: "gpt-4",
Model: "openai/gpt-4",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
@ -124,11 +135,17 @@ func TestListAvailableModels_WithModels(t *testing.T) {
},
},
ModelList: []*config.ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: config.SecureStrings{config.NewSecureString("test")}},
{
ModelName: "gpt-4",
Model: "openai/gpt-4",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "claude-3",
Model: "anthropic/claude-3",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{ModelName: "no-key-model", Model: "openai/test"},
},
@ -158,11 +175,13 @@ func TestSetDefaultModel_ValidModel(t *testing.T) {
ModelName: "new-model",
Model: "openai/new-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "old-model",
Model: "openai/old-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
@ -194,6 +213,7 @@ func TestSetDefaultModel_InvalidModel(t *testing.T) {
ModelName: "existing-model",
Model: "openai/existing",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
@ -215,6 +235,7 @@ func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
ModelName: "existing-model",
Model: "openai/existing",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{ModelName: "no-key-model", Model: "openai/nokey"},
},
@ -238,6 +259,7 @@ func TestSetDefaultModel_SaveConfigError(t *testing.T) {
ModelName: "new-model",
Model: "openai/new-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
@ -283,6 +305,7 @@ func TestModelCommandExecution_Show(t *testing.T) {
ModelName: "test-model",
Model: "openai/test",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
@ -314,11 +337,13 @@ func TestModelCommandExecution_Set(t *testing.T) {
ModelName: "old-model",
Model: "openai/old",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "new-model",
Model: "openai/new",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
@ -356,16 +381,19 @@ func TestListAvailableModels_MarkerLogic(t *testing.T) {
ModelName: "first-model",
Model: "openai/first",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "middle-model",
Model: "openai/middle",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "last-model",
Model: "openai/last",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}

View file

@ -38,12 +38,12 @@ PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gat
```json
{
"gateway": {
"log_level": "fatal"
"log_level": "warn"
}
}
```
When omitted, the default is `fatal`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`.
When omitted, the default is `warn`. Supported values: `debug`, `info`, `warn`, `error`, `fatal`.
You can also override this with the environment variable `PICOCLAW_LOG_LEVEL`.

View file

@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Niveau de Log du Gateway
`gateway.log_level` contrôle la verbosité des logs du Gateway, configurable dans `config.json` :
```json
{
"gateway": {
"log_level": "warn"
}
}
```
La valeur par défaut est `warn`. Valeurs supportées : `debug`, `info`, `warn`, `error`, `fatal`.
Peut également être surchargé via la variable d'environnement : `PICOCLAW_LOG_LEVEL=info`
### Structure du Workspace
PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :

View file

@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Gateway ログレベル
`gateway.log_level` は Gateway のログ詳細度を制御します。`config.json` で設定できます:
```json
{
"gateway": {
"log_level": "warn"
}
}
```
デフォルト値は `warn` です。サポートされる値:`debug``info``warn``error``fatal`
環境変数でも上書き可能です:`PICOCLAW_LOG_LEVEL=info`
### ワークスペースレイアウト
PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:

View file

@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Nível de Log do Gateway
`gateway.log_level` controla a verbosidade dos logs do Gateway, configurável em `config.json`:
```json
{
"gateway": {
"log_level": "warn"
}
}
```
O valor padrão é `warn`. Valores suportados: `debug`, `info`, `warn`, `error`, `fatal`.
Também pode ser substituído pela variável de ambiente: `PICOCLAW_LOG_LEVEL=info`
### Layout do Workspace
O PicoClaw armazena dados no seu workspace configurado (padrão: `~/.picoclaw/workspace`):

View file

@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Mức Log của Gateway
`gateway.log_level` kiểm soát mức độ chi tiết của log Gateway, có thể cấu hình trong `config.json`:
```json
{
"gateway": {
"log_level": "warn"
}
}
```
Giá trị mặc định là `warn`. Các giá trị được hỗ trợ: `debug`, `info`, `warn`, `error`, `fatal`.
Cũng có thể ghi đè bằng biến môi trường: `PICOCLAW_LOG_LEVEL=info`
### Bố Cục Workspace
PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):

View file

@ -31,6 +31,22 @@ PICOCLAW_HOME=/opt/picoclaw picoclaw agent
PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway
```
### Gateway 日志等级
`gateway.log_level` 控制 Gateway 的日志详细程度,可在 `config.json` 中配置:
```json
{
"gateway": {
"log_level": "warn"
}
}
```
默认值为 `warn`。支持的值:`debug``info``warn``error``fatal`
也可通过环境变量覆盖:`PICOCLAW_LOG_LEVEL=info`
### 工作区布局 (Workspace Layout)
PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`

26
go.mod
View file

@ -7,9 +7,10 @@ require (
github.com/BurntSushi/toml v1.6.0
github.com/adhocore/gronx v1.19.6
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/aws/aws-sdk-go-v2 v1.41.4
github.com/atotto/clipboard v0.1.4
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.12
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4
github.com/bwmarrin/discordgo v0.29.0
github.com/caarlos0/env/v11 v11.4.0
github.com/creack/pty v1.1.24
@ -36,22 +37,21 @@ require (
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4
golang.org/x/oauth2 v0.36.0
golang.org/x/term v0.41.0
golang.org/x/time v0.14.0
golang.org/x/time v0.15.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.26.4
modernc.org/sqlite v1.46.1
modernc.org/sqlite v1.47.0
rsc.io/qr v0.2.0
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
@ -66,6 +66,8 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@ -82,9 +84,13 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
go.mau.fi/libsignal v0.2.1 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/text v0.35.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
@ -95,7 +101,7 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.1.32
github.com/github/copilot-sdk/go v0.2.0
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect

67
go.sum
View file

@ -19,24 +19,24 @@ github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAf
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 h1:3kGOqnh1pPeddVa/E37XNTaWJ8W6vrbYV9lJEkCnhuY=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 h1:x0eGAWpd1B5I/vMtrB4Q4Zuc3CXWI8wjHfPPqBSrKmM=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2/go.mod h1:V9oTWSDC2MtS1DR71hbNET/bZ8psQp022amEBe1grJc=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4 h1:W6tKfa/s37faUnwJ71pGqsBO7/wfUX1L7tVprupQGo4=
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.4/go.mod h1:BZ+9thH0QOTDUwE8KAv/ZwUzsNC7CSMJXj/wtnZMs5k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
@ -94,8 +94,13 @@ github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uh
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/github/copilot-sdk/go v0.1.32 h1:wc9SFWwxXhJts6vyzzboPLJqcEJGnHE8rMCAY1RrUgo=
github.com/github/copilot-sdk/go v0.1.32/go.mod h1:qc2iEF7hdO8kzSvbyGvrcGhuk2fzdW4xTtT0+1EH2ts=
github.com/github/copilot-sdk/go v0.2.0 h1:RnrIIirmtp4wGgqSQFJ2k9phbeveIxOtYZqDogoNEa0=
github.com/github/copilot-sdk/go v0.2.0/go.mod h1:uGWkjVYcp2DV9DgtqYihh5tEoJjNqxIFaUNnrwY4FxM=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
@ -157,8 +162,9 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -209,8 +215,9 @@ github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoX
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
@ -277,6 +284,14 @@ go.mau.fi/util v0.9.7 h1:AWGNbJfz1zRcQOKeOEYhKUG2fT+/26Gy6kyqcH8tnBg=
go.mau.fi/util v0.9.7/go.mod h1:5T2f3ZWZFAGgmFwg3dGw7YK6kIsb9lryDzvynoR98pE=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4 h1:hsmlwsM+VqfF70cpdZEeIUKer2XWCQmQPK0u0tHy3ZQ=
go.mau.fi/whatsmeow v0.0.0-20260219150138-7ae702b1eed4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
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=
@ -363,8 +378,8 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@ -407,18 +422,18 @@ maunium.net/go/mautrix v0.26.4 h1:enHSnkf0L2V9+VnfJfNhKSReSW6pBKS/x3Su+v+Vovs=
maunium.net/go/mautrix v0.26.4/go.mod h1:YWw8NWTszsbyFAznboicBObwHPgTSLcuTbVX2kY7U2M=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@ -427,8 +442,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View file

@ -75,6 +75,8 @@ type processOptions struct {
SessionKey string // Session identifier for history/context
Channel string // Target channel for tool execution
ChatID string // Target chat ID for tool execution
MessageID string // Current inbound platform message ID
ReplyToMessageID string // Current inbound reply target message ID
SenderID string // Current sender ID for dynamic context
SenderDisplayName string // Current sender display name for dynamic context
UserMessage string // User message content (may include prefix)
@ -104,6 +106,7 @@ const (
metadataKeyAccountID = "account_id"
metadataKeyGuildID = "guild_id"
metadataKeyTeamID = "team_id"
metadataKeyReplyToMessage = "reply_to_message_id"
metadataKeyParentPeerKind = "parent_peer_kind"
metadataKeyParentPeerID = "parent_peer_id"
)
@ -222,17 +225,37 @@ func registerSharedTools(
// Message tool
if cfg.Tools.IsToolEnabled("message") {
messageTool := tools.NewMessageTool()
messageTool.SetSendCallback(func(channel, chatID, content string) error {
messageTool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: content,
Channel: channel,
ChatID: chatID,
Content: content,
ReplyToMessageID: replyToMessageID,
})
})
agent.Tools.Register(messageTool)
}
if cfg.Tools.IsToolEnabled("reaction") {
reactionTool := tools.NewReactionTool()
reactionTool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
if al.channelManager == nil {
return fmt.Errorf("channel manager not configured")
}
ch, ok := al.channelManager.GetChannel(channel)
if !ok {
return fmt.Errorf("channel %s not found", channel)
}
rc, ok := ch.(channels.ReactionCapable)
if !ok {
return fmt.Errorf("channel %s does not support reactions", channel)
}
_, err := rc.ReactToMessage(ctx, chatID, messageID)
return err
})
agent.Tools.Register(reactionTool)
}
// Send file tool (outbound media via MediaStore — store injected later by SetMediaStore)
if cfg.Tools.IsToolEnabled("send_file") {
@ -1315,6 +1338,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
MessageID: msg.MessageID,
ReplyToMessageID: inboundMetadata(msg, metadataKeyReplyToMessage),
SenderID: msg.SenderID,
SenderDisplayName: msg.Sender.DisplayName,
UserMessage: msg.Content,
@ -2384,8 +2409,15 @@ turnLoop:
}
toolStart := time.Now()
toolResult := ts.agent.Tools.ExecuteWithContext(
execCtx := tools.WithToolInboundContext(
turnCtx,
ts.channel,
ts.chatID,
ts.opts.MessageID,
ts.opts.ReplyToMessageID,
)
toolResult := ts.agent.Tools.ExecuteWithContext(
execCtx,
toolName,
toolArgs,
ts.channel,

View file

@ -531,6 +531,20 @@ func TestToolContext_Updates(t *testing.T) {
if got := tools.ToolChannel(context.Background()); got != "" {
t.Errorf("expected empty channel from bare context, got %q", got)
}
inboundCtx := tools.WithToolInboundContext(
context.Background(),
"telegram",
"chat-42",
"msg-123",
"msg-100",
)
if got := tools.ToolMessageID(inboundCtx); got != "msg-123" {
t.Errorf("expected messageID 'msg-123', got %q", got)
}
if got := tools.ToolReplyToMessageID(inboundCtx); got != "msg-100" {
t.Errorf("expected replyToMessageID 'msg-100', got %q", got)
}
}
// TestToolRegistry_GetDefinitions verifies tool definitions can be retrieved

View file

@ -245,15 +245,18 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
// ReactToMessage implements channels.ReactionCapable.
// Adds a reaction (randomly chosen from config) and returns an undo function to remove it.
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
// Get emoji list from config
emojiList := c.config.RandomReactionEmoji
var chosenEmoji string
if len(emojiList) == 0 {
// Default to "Pin" if no config
chosenEmoji = "Pin"
} else {
idx := rand.Intn(len(emojiList))
chosenEmoji = emojiList[idx]
// Get emoji list from config (Feishu emoji_type keys, e.g. Pin, THUMBSUP).
// Ignore empty entries so a list like ["", "Pin"] does not randomly pick "" (API 231001).
var candidates []string
for _, e := range c.config.RandomReactionEmoji {
e = strings.TrimSpace(e)
if e != "" {
candidates = append(candidates, e)
}
}
chosenEmoji := "Pin"
if len(candidates) > 0 {
chosenEmoji = candidates[rand.Intn(len(candidates))]
}
req := larkim.NewCreateMessageReactionReqBuilder().

View file

@ -7,8 +7,8 @@ import (
"math/rand"
"os"
"path/filepath"
"strings"
"sync/atomic"
"time"
"github.com/caarlos0/env/v11"
@ -20,89 +20,8 @@ import (
// rrCounter is a global counter for round-robin load balancing across models.
var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
// It also supports parsing comma-separated strings from environment variables,
// including both English (,) and Chinese () commas.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
// Accept a single JSON string for convenience, e.g.:
// "text": "Thinking..."
var singleString string
if err := json.Unmarshal(data, &singleString); err == nil {
*f = FlexibleStringSlice{singleString}
return nil
}
// Accept a single JSON number too, to keep symmetry with mixed allow_from
// payloads that may contain numeric identifiers.
var singleNumber float64
if err := json.Unmarshal(data, &singleNumber); err == nil {
*f = FlexibleStringSlice{fmt.Sprintf("%.0f", singleNumber)}
return nil
}
// Try []string first
var ss []string
if err := json.Unmarshal(data, &ss); err == nil {
*f = ss
return nil
}
// Try []interface{} to handle mixed types
var raw []any
if err := json.Unmarshal(data, &raw); err != nil {
var s string
// fail over to compatible to old format string
if err = json.Unmarshal(data, &s); err != nil {
return err
}
*f = []string{s}
return nil
}
result := make([]string, 0, len(raw))
for _, v := range raw {
switch val := v.(type) {
case string:
result = append(result, val)
case float64:
result = append(result, fmt.Sprintf("%.0f", val))
default:
result = append(result, fmt.Sprintf("%v", val))
}
}
*f = result
return nil
}
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
// It handles comma-separated values with both English (,) and Chinese () commas.
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
if len(text) == 0 {
*f = nil
return nil
}
s := string(text)
// Replace Chinese comma with English comma, then split
s = strings.ReplaceAll(s, "", ",")
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
*f = result
return nil
}
// CurrentVersion is the latest config schema version
const CurrentVersion = 1
const CurrentVersion = 2
// Config is the current config structure with version support
type Config struct {
@ -675,6 +594,11 @@ type ModelConfig struct {
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty"` // API authentication keys (multiple keys for failover)
// Enabled indicates whether this model entry is active. When omitted in
// existing configs, the field is inferred during load: models with API keys
// or the reserved "local-model" name are auto-enabled.
Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"`
// isVirtual marks this model as a virtual model generated from multi-key expansion.
// Virtual models should not be persisted to config files.
isVirtual bool
@ -1047,6 +971,35 @@ func LoadConfig(path string) (*Config, error) {
defer func(cfg *Config) {
_ = SaveConfig(path, cfg)
}(cfg)
case 1:
// V1→V2 migration: infer Enabled and migrate channel config fields
logger.InfoF("config migrate start", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
cfg, err = loadConfig(data)
if err != nil {
return nil, err
}
secPath := securityPath(path)
err = loadSecurityConfig(cfg, secPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("failed to load security config: %w", err)
}
oldCfg := &configV1{Config: *cfg}
cfg, err = oldCfg.Migrate()
if err != nil {
logger.ErrorF("config migrate fail", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
return nil, err
}
err = makeBackup(path)
if err != nil {
return nil, err
}
defer func(cfg *Config) {
_ = SaveConfig(path, cfg)
}(cfg)
logger.InfoF("config migrate success", map[string]any{"from": versionInfo.Version, "to": CurrentVersion})
case CurrentVersion:
// Current version
cfg, err = loadConfig(data)
@ -1064,18 +1017,15 @@ func LoadConfig(path string) (*Config, error) {
return nil, fmt.Errorf("unsupported config version: %d", versionInfo.Version)
}
if err := env.Parse(cfg); err != nil {
if err = env.Parse(cfg); err != nil {
return nil, err
}
// Expand multi-key configs into separate entries for key-level failover
cfg.ModelList = expandMultiKeyModels(cfg.ModelList)
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
// Validate model_list for uniqueness and required fields
if err := cfg.ValidateModelList(); err != nil {
if err = cfg.ValidateModelList(); err != nil {
return nil, err
}
@ -1092,12 +1042,22 @@ func makeBackup(path string) error {
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
}
// Create backup of the config file before migration
bakPath := path + ".bak"
dateSuffix := time.Now().Format(".20060102.bak")
// Backup config file
bakPath := path + dateSuffix
if err := fileutil.CopyFile(path, bakPath, 0o600); err != nil {
logger.ErrorF("failed to create config backup", map[string]any{"error": err})
return fmt.Errorf("failed to create config backup: %w", err)
}
// Backup security config file
secPath := securityPath(path)
if _, err := os.Stat(secPath); err == nil {
secBakPath := secPath + dateSuffix
if secErr := fileutil.CopyFile(secPath, secBakPath, 0o600); secErr != nil {
logger.ErrorF("failed to create security backup", map[string]any{"error": secErr})
return fmt.Errorf("failed to create security backup: %w", secErr)
}
}
return nil
}
@ -1113,19 +1073,6 @@ func toNameIndex(list []*ModelConfig) []string {
return nameList
}
func (c *Config) migrateChannelConfigs() {
// Discord: mention_only -> group_trigger.mention_only
if c.Channels.Discord.MentionOnly && !c.Channels.Discord.GroupTrigger.MentionOnly {
c.Channels.Discord.GroupTrigger.MentionOnly = true
}
// OneBot: group_trigger_prefix -> group_trigger.prefixes
if len(c.Channels.OneBot.GroupTriggerPrefix) > 0 &&
len(c.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
c.Channels.OneBot.GroupTrigger.Prefixes = c.Channels.OneBot.GroupTriggerPrefix
}
}
func SaveConfig(path string, cfg *Config) error {
if cfg.Version < CurrentVersion {
cfg.Version = CurrentVersion
@ -1139,6 +1086,10 @@ func SaveConfig(path string, cfg *Config) error {
}
// Temporarily replace ModelList with filtered version for serialization
originalModelList := cfg.ModelList
defer func() {
// Restore original ModelList after serialization
cfg.ModelList = originalModelList
}()
cfg.ModelList = nonVirtualModels
if err := saveSecurityConfig(securityPath(path), cfg); err != nil {
@ -1147,8 +1098,6 @@ func SaveConfig(path string, cfg *Config) error {
}
data, err := json.MarshalIndent(cfg, "", " ")
// Restore original ModelList after serialization
cfg.ModelList = originalModelList
if err != nil {
return err
}
@ -1218,29 +1167,6 @@ func (c *Config) SecurityCopyFrom(path string) error {
return loadSecurityConfig(c, securityPath(path))
}
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
seen := make(map[string]struct{})
var all []string
if k := strings.TrimSpace(apiKey); k != "" {
if _, exists := seen[k]; !exists {
seen[k] = struct{}{}
all = append(all, k)
}
}
for _, k := range apiKeys {
if trimmed := strings.TrimSpace(k); trimmed != "" {
if _, exists := seen[trimmed]; !exists {
seen[trimmed] = struct{}{}
all = append(all, trimmed)
}
}
}
return all
}
// expandMultiKeyModels expands ModelConfig entries with multiple API keys into
// separate entries for key-level failover. Each key gets its own ModelConfig entry,
// and the original entry's fallbacks are set up to chain through the expanded entries.

View file

@ -734,7 +734,8 @@ func (c *configV0) Migrate() (*Config, error) {
// Convert []modelConfigV0 to []ModelConfig
cfg.ModelList = make([]*ModelConfig, len(c.ModelList))
for i, m := range c.ModelList {
cfg.ModelList[i] = &ModelConfig{
mergedKeys := toSecureStrings(mergeAPIKeys(m.APIKey, m.APIKeys))
mc := &ModelConfig{
ModelName: m.ModelName,
Model: m.Model,
APIBase: m.APIBase,
@ -747,8 +748,13 @@ func (c *configV0) Migrate() (*Config, error) {
MaxTokensField: m.MaxTokensField,
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
APIKeys: toSecureStrings(MergeAPIKeys(m.APIKey, m.APIKeys)),
APIKeys: mergedKeys,
}
// Infer Enabled during V0→V1 migration
if len(mergedKeys) > 0 || m.ModelName == "local-model" {
mc.Enabled = true
}
cfg.ModelList[i] = mc
}
}
@ -756,6 +762,52 @@ func (c *configV0) Migrate() (*Config, error) {
return cfg, nil
}
type configV1 struct {
Config
}
// Migrate applies V1→Current Version migrations to an already-loaded Config.
//
// It must be called AFTER loadSecurityConfig so that API keys (which live in
// the security file) are available for the Enabled inference.
func (c *configV1) Migrate() (*Config, error) {
c.migrateModelEnabled()
c.migrateChannelConfigs()
return &c.Config, nil
}
// migrateModelEnabled infers the Enabled field for models loaded from V1 configs
// that predate the field (JSON where "enabled" is absent).
//
// Rules (only applied when Enabled has not been explicitly set by the user):
// - Models with API keys are considered enabled.
// - The reserved "local-model" entry is considered enabled.
func (cfg *configV1) migrateModelEnabled() {
for _, m := range cfg.ModelList {
if m.Enabled {
continue
}
if len(m.APIKeys) > 0 || m.ModelName == "local-model" {
m.Enabled = true
}
}
}
// migrateChannelConfigs migrates legacy channel config fields in a V1 Config
// to the new unified structures.
func (cfg *configV1) migrateChannelConfigs() {
// Discord: mention_only -> group_trigger.mention_only
if cfg.Channels.Discord.MentionOnly && !cfg.Channels.Discord.GroupTrigger.MentionOnly {
cfg.Channels.Discord.GroupTrigger.MentionOnly = true
}
// OneBot: group_trigger_prefix -> group_trigger.prefixes
if len(cfg.Channels.OneBot.GroupTriggerPrefix) > 0 &&
len(cfg.Channels.OneBot.GroupTrigger.Prefixes) == 0 {
cfg.Channels.OneBot.GroupTrigger.Prefixes = cfg.Channels.OneBot.GroupTriggerPrefix
}
}
type webToolsConfigV0 struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_WEB_"`
Brave braveConfigV0 ` json:"brave"`
@ -791,7 +843,7 @@ func (v *braveConfigV0) ToBraveConfig() BraveConfig {
return BraveConfig{
Enabled: v.Enabled,
MaxResults: v.MaxResults,
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)),
}
}
@ -808,7 +860,7 @@ func (v *tavilyConfigV0) ToTavilyConfig() TavilyConfig {
Enabled: v.Enabled,
BaseURL: v.BaseURL,
MaxResults: v.MaxResults,
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)),
}
}
@ -823,7 +875,7 @@ func (v *perplexityConfigV0) ToPerplexityConfig() PerplexityConfig {
return PerplexityConfig{
Enabled: v.Enabled,
MaxResults: v.MaxResults,
APIKeys: toSecureStrings(MergeAPIKeys(v.APIKey, v.APIKeys)),
APIKeys: toSecureStrings(mergeAPIKeys(v.APIKey, v.APIKeys)),
}
}

327
pkg/config/config_struct.go Normal file
View file

@ -0,0 +1,327 @@
package config
import (
"encoding/json"
"fmt"
"path/filepath"
"runtime"
"strings"
"sync"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/pkg/logger"
)
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
// It also supports parsing comma-separated strings from environment variables,
// including both English (,) and Chinese () commas.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
// Accept a single JSON string for convenience, e.g.:
// "text": "Thinking..."
var singleString string
if err := json.Unmarshal(data, &singleString); err == nil {
*f = FlexibleStringSlice{singleString}
return nil
}
// Accept a single JSON number too, to keep symmetry with mixed allow_from
// payloads that may contain numeric identifiers.
var singleNumber float64
if err := json.Unmarshal(data, &singleNumber); err == nil {
*f = FlexibleStringSlice{fmt.Sprintf("%.0f", singleNumber)}
return nil
}
// Try []string first
var ss []string
if err := json.Unmarshal(data, &ss); err == nil {
*f = ss
return nil
}
// Try []interface{} to handle mixed types
var raw []any
if err := json.Unmarshal(data, &raw); err != nil {
var s string
// fail over to compatible to old format string
if err = json.Unmarshal(data, &s); err != nil {
return err
}
*f = []string{s}
return nil
}
result := make([]string, 0, len(raw))
for _, v := range raw {
switch val := v.(type) {
case string:
result = append(result, val)
case float64:
result = append(result, fmt.Sprintf("%.0f", val))
default:
result = append(result, fmt.Sprintf("%v", val))
}
}
*f = result
return nil
}
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
// It handles comma-separated values with both English (,) and Chinese () commas.
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
if len(text) == 0 {
*f = nil
return nil
}
s := string(text)
// Replace Chinese comma with English comma, then split
s = strings.ReplaceAll(s, "", ",")
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
*f = result
return nil
}
const (
notHere = `"[NOT_HERE]"`
)
// SecureStrings is a slice of SecureString
type SecureStrings []*SecureString
// Values returns the decrypted/resolved values
func (s *SecureStrings) Values() []string {
if s == nil {
return nil
}
keys := make([]string, len(*s))
for i, k := range *s {
keys[i] = k.String()
}
return unique(keys)
}
func SimpleSecureStrings(val ...string) SecureStrings {
val = unique(val)
vv := make(SecureStrings, len(val))
for i, s := range val {
vv[i] = NewSecureString(s)
}
return vv
}
// unique returns a new slice with duplicate elements removed.
func unique[T comparable](input []T) []T {
m := make(map[T]struct{})
var result []T
for _, v := range input {
if _, ok := m[v]; !ok {
m[v] = struct{}{}
result = append(result, v)
}
}
return result
}
func (s SecureStrings) MarshalJSON() ([]byte, error) {
return []byte(notHere), nil
}
func (s *SecureStrings) UnmarshalJSON(value []byte) error {
if string(value) == notHere {
return nil
}
var v []*SecureString
err := json.Unmarshal(value, &v)
if err != nil {
return err
}
*s = v
return nil
}
// SecureString the string value that can be decrypted or resolved
//
//nolint:recvcheck
type SecureString struct {
resolved string // Decrypted/resolved value returned by String()
raw string // Persisted raw value (enc://, file://, or plaintext)
}
func callerFromYaml() bool {
_, file, _, ok := runtime.Caller(2)
if ok {
d := filepath.Dir(file)
// check the caller is from yaml.v
if !strings.Contains(d, "yaml.v") {
return true
}
}
return false
}
// IsZero returns true if the SecureString is empty
// if caller not yaml, just return true for prevent marshal this field
func (s SecureString) IsZero() bool {
if callerFromYaml() {
return true
}
return s.resolved == ""
}
func NewSecureString(value string) *SecureString {
s := &SecureString{}
if err := s.fromRaw(value); err != nil {
logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err))
}
return s
}
func (s *SecureString) String() string {
if s == nil {
return ""
}
return s.resolved
}
func (s *SecureString) Set(value string) *SecureString {
s.resolved = value
s.raw = ""
return s
}
func (s SecureString) MarshalJSON() ([]byte, error) {
return []byte(notHere), nil
}
func (s *SecureString) UnmarshalJSON(value []byte) error {
if string(value) == notHere {
return nil
}
var v string
if err := json.Unmarshal(value, &v); err != nil {
return err
}
return s.fromRaw(v)
}
func (s SecureString) MarshalYAML() (any, error) {
// Preserve raw value if it is already a reference (enc:// or file://)
if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) {
return s.raw, nil
}
// If resolved is a reference format (e.g. set via Set), copy back to raw
if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) {
s.raw = s.resolved
return s.raw, nil
}
// Try to encrypt the resolved value
if passphrase := credential.PassphraseProvider(); passphrase != "" {
encrypted, err := credential.Encrypt(passphrase, "", s.resolved)
if err != nil {
logger.Errorf("Encrypt error: %v", err)
return nil, err
}
s.raw = encrypted
} else {
s.raw = s.resolved
}
return s.raw, nil
}
func (s *SecureString) UnmarshalYAML(value *yaml.Node) error {
return s.fromRaw(value.Value)
}
func (s *SecureString) fromRaw(v string) error {
s.raw = v
vv, err := resolveKey(v)
if err != nil {
return err
}
s.resolved = vv
return nil
}
var (
secResolverMu sync.RWMutex
secResolver *credential.Resolver
)
func updateResolver(path string) {
secResolverMu.Lock()
defer secResolverMu.Unlock()
secResolver = credential.NewResolver(path)
}
func resolveKey(v string) (string, error) {
secResolverMu.RLock()
resolver := secResolver
secResolverMu.RUnlock()
if resolver == nil {
resolver = credential.NewResolver("")
}
if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") {
decrypted, err := resolver.Resolve(v)
if err != nil {
logger.Errorf("Resolve error: %v", err)
return "", err
}
return decrypted, nil
}
return v, nil
}
func (s *SecureString) UnmarshalText(text []byte) error {
v := string(text)
return s.fromRaw(v)
}
type SecureModelList []*ModelConfig
func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error {
mm := make(map[string]*ModelConfig)
if err := value.Decode(&mm); err != nil {
logger.Errorf("Decode error: %v", err)
return err
}
nameList := toNameIndex(*v)
for i, m := range *v {
sec := mm[nameList[i]]
if sec == nil {
sec = mm[m.ModelName]
}
if sec != nil {
m.APIKeys = sec.APIKeys
}
}
return nil
}
func (v SecureModelList) MarshalYAML() (any, error) {
type onlySecureData struct {
APIKeys SecureStrings `yaml:"api_keys,omitempty"`
}
mm := make(map[string]onlySecureData)
nameList := toNameIndex(v)
for i, m := range v {
mm[nameList[i]] = onlySecureData{
APIKeys: m.APIKeys,
}
}
return mm, nil
}

View file

@ -0,0 +1,145 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/caarlos0/env/v11"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/credential"
)
func TestLoadSecurityValue(t *testing.T) {
type valueStruct struct {
Url string `json:"url,omitempty" yaml:"-"`
Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"`
ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"`
}
type testStruct struct {
Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
}
v1 := &testStruct{
Pico: &valueStruct{
Url: "https://example.com",
Token: NewSecureString("token1"),
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
},
}
bytes, err := yaml.Marshal(v1)
assert.NoError(t, err)
jsonBytes, err := json.Marshal(v1)
assert.NoError(t, err)
const want = `pico:
token: token1
api_keys:
- api-key1
- api-key2
`
const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}`
v0 := &testStruct{}
err = json.Unmarshal([]byte(jsonPost), v0)
assert.NoError(t, err)
assert.Equal(t, "https://example.com", v0.Pico.Url)
assert.Equal(t, "token0", v0.Pico.Token.String())
const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}`
assert.Equal(t, want, string(bytes))
assert.Equal(t, jsonWant, string(jsonBytes))
v2 := &testStruct{}
err = json.Unmarshal(jsonBytes, v2)
assert.NoError(t, err)
err = yaml.Unmarshal(bytes, v2)
assert.NoError(t, err)
assert.Equal(t, "https://example.com", v2.Pico.Url)
if v2.Pico.Token != nil {
assert.Equal(t, "token1", v2.Pico.Token.String())
assert.Equal(t, "token1", v2.Pico.Token.raw)
}
v2.Pico.Token = NewSecureString("token1")
v2.Pico.Token.raw = "abc"
err = yaml.Unmarshal(bytes, v2)
assert.NoError(t, err)
assert.Equal(t, "token1", v2.Pico.Token.raw)
os.Setenv("PICO_TOKEN", "token_env")
err = env.Parse(v2)
assert.NoError(t, err)
assert.NotNil(t, v2.Pico.Token)
assert.Equal(t, "token1", v2.Pico.Token.String())
v3 := &testStruct{Pico: &valueStruct{}}
err = env.Parse(v3)
assert.NoError(t, err)
if v3.Pico.Token != nil {
assert.Equal(t, "token_env", v3.Pico.Token.String())
}
type toolsStruct struct {
Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
}
type testStruct2 struct {
Tools toolsStruct `json:"tools,omitempty" yaml:",inline"`
}
v4 := &testStruct2{
Tools: toolsStruct{
Pico: valueStruct{
Url: "https://example.com",
Token: NewSecureString("token1"),
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
},
},
}
bytes, err = yaml.Marshal(v4)
assert.NoError(t, err)
assert.Equal(t, want, string(bytes))
jsonBytes, err = json.Marshal(v4)
assert.NoError(t, err)
assert.Equal(
t,
`{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`,
string(jsonBytes),
)
v5 := &testStruct2{}
err = json.Unmarshal(jsonBytes, v5)
assert.NoError(t, err)
assert.Equal(t, "https://example.com", v5.Tools.Pico.Url)
err = yaml.Unmarshal(bytes, v5)
assert.NoError(t, err)
assert.NotNil(t, v5.Tools.Pico.Token)
assert.Equal(t, "token1", v5.Tools.Pico.Token.raw)
dir := t.TempDir()
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
const passphrase = "test-passphrase-32bytes-long-ok!"
t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath)
t.Setenv(credential.PassphraseEnvVar, passphrase)
v5.Tools.Pico.Token.Set("newtoken1")
v5.Tools.Pico.ApiKeys[0].Set("newapi-key1")
bytes, err = yaml.Marshal(v5)
assert.NoError(t, err)
t.Logf("yaml: %s", string(bytes))
v6 := &testStruct2{}
err = yaml.Unmarshal(bytes, v6)
assert.NoError(t, err)
assert.NotNil(t, v6.Tools.Pico.Token)
assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String())
}

View file

@ -1673,3 +1673,163 @@ func TestFilterSensitiveData_AllTokenTypes(t *testing.T) {
})
}
}
// ---------------------------------------------------------------------------
// makeBackup tests
// ---------------------------------------------------------------------------
// TestMakeBackup_WithDateSuffix verifies backup files include a date suffix.
func TestMakeBackup_WithDateSuffix(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"version":2}`), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := makeBackup(configPath); err != nil {
t.Fatalf("makeBackup: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
var hasDatedBackup bool
for _, e := range entries {
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
hasDatedBackup = true
// Verify backup content matches original
bakPath := filepath.Join(dir, e.Name())
data, err := os.ReadFile(bakPath)
if err != nil {
t.Fatalf("ReadFile backup: %v", err)
}
if string(data) != `{"version":2}` {
t.Errorf("backup content = %q, want original content", string(data))
}
break
}
}
if !hasDatedBackup {
t.Error("expected backup file with date suffix pattern config.json.20*.bak")
}
}
// TestMakeBackup_AlsoBacksSecurityFile verifies that the security config file
// is also backed up with the same date suffix.
func TestMakeBackup_AlsoBacksSecurityFile(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
secPath := securityPath(configPath)
os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
os.WriteFile(secPath, []byte(`model_list:\n test:0:\n api_keys:\n - "sk-test"\n`), 0o600)
if err := makeBackup(configPath); err != nil {
t.Fatalf("makeBackup: %v", err)
}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
configBackups := 0
secBackups := 0
for _, e := range entries {
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
configBackups++
}
if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched {
secBackups++
}
}
if configBackups != 1 {
t.Errorf("expected 1 config backup, got %d", configBackups)
}
if secBackups != 1 {
t.Errorf("expected 1 security backup, got %d", secBackups)
}
}
// TestMakeBackup_NonexistentFileSkipsBackup verifies that makeBackup returns nil
// when the config file does not exist (no error, no panic).
func TestMakeBackup_NonexistentFileSkipsBackup(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "nonexistent.json")
if err := makeBackup(configPath); err != nil {
t.Fatalf("makeBackup on nonexistent file should return nil, got: %v", err)
}
}
// TestMakeBackup_OnlyConfigNoSecurity verifies backup succeeds when only
// the config file exists and no security file.
func TestMakeBackup_OnlyConfigNoSecurity(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
if err := makeBackup(configPath); err != nil {
t.Fatalf("makeBackup: %v", err)
}
entries, _ := os.ReadDir(dir)
configBackups := 0
secBackups := 0
for _, e := range entries {
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
configBackups++
}
if matched, _ := filepath.Match(".security.yml.20*.bak", e.Name()); matched {
secBackups++
}
}
if configBackups != 1 {
t.Errorf("expected 1 config backup, got %d", configBackups)
}
if secBackups != 0 {
t.Errorf("expected 0 security backups when no security file exists, got %d", secBackups)
}
}
// TestMakeBackup_SameDateSuffix verifies that config and security backups
// share the same date suffix (they are created in the same makeBackup call).
func TestMakeBackup_SameDateSuffix(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
secPath := securityPath(configPath)
os.WriteFile(configPath, []byte(`{"version":2}`), 0o600)
os.WriteFile(secPath, []byte(`key: value`), 0o600)
if err := makeBackup(configPath); err != nil {
t.Fatalf("makeBackup: %v", err)
}
entries, _ := os.ReadDir(dir)
var configDate, secDate string
for _, e := range entries {
name := e.Name()
// Extract date part: after the last . before .bak
// e.g. config.json.20260330.bak → 20260330
if strings.HasPrefix(name, "config.json.") && strings.HasSuffix(name, ".bak") {
configDate = strings.TrimPrefix(name, "config.json.")
configDate = strings.TrimSuffix(configDate, ".bak")
}
if strings.HasPrefix(name, ".security.yml.") && strings.HasSuffix(name, ".bak") {
secDate = strings.TrimPrefix(name, ".security.yml.")
secDate = strings.TrimSuffix(secDate, ".bak")
}
}
if configDate == "" {
t.Fatal("config backup file not found")
}
if secDate == "" {
t.Fatal("security backup file not found")
}
if configDate != secDate {
t.Errorf("config backup date = %q, security backup date = %q, should match", configDate, secDate)
}
}

View file

@ -534,3 +534,26 @@ func loadConfig(data []byte) (*Config, error) {
}
return cfg, nil
}
func mergeAPIKeys(apiKey string, apiKeys []string) []string {
seen := make(map[string]struct{})
var all []string
if k := strings.TrimSpace(apiKey); k != "" {
if _, exists := seen[k]; !exists {
seen[k] = struct{}{}
all = append(all, k)
}
}
for _, k := range apiKeys {
if trimmed := strings.TrimSpace(k); trimmed != "" {
if _, exists := seen[trimmed]; !exists {
seen[trimmed] = struct{}{}
all = append(all, trimmed)
}
}
}
return all
}

View file

@ -681,3 +681,473 @@ web:
t.Error("Discord token not preserved in .security.yml file")
}
}
// ---------------------------------------------------------------------------
// V1 → V2 migration tests
// ---------------------------------------------------------------------------
// TestMigrateModelEnabled_APIKeysInferredEnabled verifies that models with API keys
// are marked as enabled during V1→V2 migration.
func TestMigrateModelEnabled_APIKeysInferredEnabled(t *testing.T) {
v1 := &configV1{Config: Config{
ModelList: []*ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
{ModelName: "claude", Model: "anthropic/claude", APIKeys: SimpleSecureStrings("sk-ant")},
},
}}
v1.migrateModelEnabled()
for _, m := range v1.ModelList {
if !m.Enabled {
t.Errorf("model %q with API key should be enabled", m.ModelName)
}
}
}
// TestMigrateModelEnabled_LocalModelInferredEnabled verifies that the reserved
// "local-model" entry is enabled even without API keys.
func TestMigrateModelEnabled_LocalModelInferredEnabled(t *testing.T) {
v1 := &configV1{Config: Config{
ModelList: []*ModelConfig{
{ModelName: "local-model", Model: "vllm/custom-model", APIBase: "http://localhost:8000/v1"},
},
}}
v1.migrateModelEnabled()
if !v1.ModelList[0].Enabled {
t.Error("local-model should be enabled")
}
}
// TestMigrateModelEnabled_NoKeyStaysDisabled verifies that models without API keys
// and not named "local-model" remain disabled.
func TestMigrateModelEnabled_NoKeyStaysDisabled(t *testing.T) {
v1 := &configV1{Config: Config{
ModelList: []*ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4"},
{ModelName: "claude", Model: "anthropic/claude"},
},
}}
v1.migrateModelEnabled()
for _, m := range v1.ModelList {
if m.Enabled {
t.Errorf("model %q without API key should stay disabled", m.ModelName)
}
}
}
// TestMigrateModelEnabled_ExplicitEnabledPreserved verifies that a model with
// explicitly enabled=true is NOT overridden by the migration.
func TestMigrateModelEnabled_ExplicitEnabledPreserved(t *testing.T) {
v1 := &configV1{Config: Config{
ModelList: []*ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: true},
},
}}
v1.migrateModelEnabled()
if !v1.ModelList[0].Enabled {
t.Error("explicitly enabled model should remain enabled")
}
}
// TestMigrateModelEnabled_ExplicitDisabledNotOverridden verifies that a model with
// explicitly enabled=false and API keys gets enabled during migration.
// Note: since Go's zero value for bool is false and JSON omitempty omits false,
// migration cannot distinguish "explicitly false" from "field absent". Both cases
// get the same inference treatment.
func TestMigrateModelEnabled_ExplicitDisabledNotOverridden(t *testing.T) {
v1 := &configV1{Config: Config{
ModelList: []*ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test"), Enabled: false},
},
}}
v1.migrateModelEnabled()
// Even though Enabled was set to false, migration infers it as true because
// the migration cannot distinguish from a missing field (both are zero value).
if !v1.ModelList[0].Enabled {
t.Error("model with API key should be enabled by migration inference")
}
}
// TestMigrateModelEnabled_Mixed verifies a mix of models.
func TestMigrateModelEnabled_Mixed(t *testing.T) {
v1 := &configV1{Config: Config{
ModelList: []*ModelConfig{
{ModelName: "with-key", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
{ModelName: "no-key", Model: "openai/gpt-4"},
{ModelName: "local-model", Model: "vllm/custom"},
{
ModelName: "disabled-explicit",
Model: "openai/gpt-4",
APIKeys: SimpleSecureStrings("sk-test"),
Enabled: false,
},
},
}}
v1.migrateModelEnabled()
assertEnabled := func(name string, want bool) {
for _, m := range v1.ModelList {
if m.ModelName == name {
if m.Enabled != want {
t.Errorf("model %q: Enabled=%v, want %v", name, m.Enabled, want)
}
return
}
}
t.Errorf("model %q not found", name)
}
assertEnabled("with-key", true)
assertEnabled("no-key", false)
assertEnabled("local-model", true)
assertEnabled("disabled-explicit", true) // false is zero value, migration infers from API key
}
// TestMigrateChannelConfigs_DiscordMentionOnly verifies Discord mention_only migration.
func TestMigrateChannelConfigs_DiscordMentionOnly(t *testing.T) {
v1 := &configV1{Config: Config{
Channels: ChannelsConfig{
Discord: DiscordConfig{
MentionOnly: true,
},
},
}}
v1.migrateChannelConfigs()
if !v1.Channels.Discord.GroupTrigger.MentionOnly {
t.Error("Discord GroupTrigger.MentionOnly should be set to true")
}
}
// TestMigrateChannelConfigs_DiscordAlreadyMigrated is a no-op test.
func TestMigrateChannelConfigs_DiscordAlreadyMigrated(t *testing.T) {
v1 := &configV1{Config: Config{
Channels: ChannelsConfig{
Discord: DiscordConfig{
GroupTrigger: GroupTriggerConfig{MentionOnly: true},
},
},
}}
v1.migrateChannelConfigs()
}
// TestMigrateChannelConfigs_OneBotPrefix verifies OneBot prefix migration.
func TestMigrateChannelConfigs_OneBotPrefix(t *testing.T) {
v1 := &configV1{Config: Config{
Channels: ChannelsConfig{
OneBot: OneBotConfig{
GroupTriggerPrefix: []string{"/"},
},
},
}}
v1.migrateChannelConfigs()
if len(v1.Channels.OneBot.GroupTrigger.Prefixes) != 1 || v1.Channels.OneBot.GroupTrigger.Prefixes[0] != "/" {
t.Errorf("OneBot GroupTrigger.Prefixes = %v, want [\"/\"]", v1.Channels.OneBot.GroupTrigger.Prefixes)
}
}
// TestMigrateConfigV1_Combined verifies that configV1.Migrate applies both migrations.
func TestMigrateConfigV1_Combined(t *testing.T) {
v1 := &configV1{Config: Config{
ModelList: []*ModelConfig{
{ModelName: "gpt-4", Model: "openai/gpt-4", APIKeys: SimpleSecureStrings("sk-test")},
},
Channels: ChannelsConfig{
Discord: DiscordConfig{MentionOnly: true},
},
}}
result, err := v1.Migrate()
if err != nil {
t.Fatalf("Migrate: %v", err)
}
if !result.ModelList[0].Enabled {
t.Error("model with API key should be enabled after V1→V2 migration")
}
if !result.Channels.Discord.GroupTrigger.MentionOnly {
t.Error("Discord mention_only should be migrated after V1→V2 migration")
}
}
// TestLoadConfig_V1ToV2Migration verifies end-to-end V1→V2 config migration
// through LoadConfig, including Enabled field inference and version bump.
func TestLoadConfig_V1ToV2Migration(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
// Write a V1 config with model_list but no "enabled" field
v1Config := `{
"version": 1,
"model_list": [
{
"model_name": "gpt-4",
"model": "openai/gpt-4"
},
{
"model_name": "local-model",
"model": "vllm/custom-model",
"api_base": "http://localhost:8000/v1"
}
],
"channels": {
"discord": {
"mention_only": true
}
},
"gateway": {"host": "127.0.0.1", "port": 18790}
}`
if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
// Version should be bumped to 2
if cfg.Version != CurrentVersion {
t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
}
// gpt-4 has no API key → disabled
gpt4, err := cfg.GetModelConfig("gpt-4")
if err != nil {
t.Fatalf("GetModelConfig(gpt-4): %v", err)
}
if gpt4.Enabled {
t.Error("gpt-4 without API key should be disabled after migration")
}
// local-model → enabled
local, err := cfg.GetModelConfig("local-model")
if err != nil {
t.Fatalf("GetModelConfig(local-model): %v", err)
}
if !local.Enabled {
t.Error("local-model should be enabled after migration")
}
// Discord channel config should be migrated
if !cfg.Channels.Discord.GroupTrigger.MentionOnly {
t.Error("Discord mention_only should be migrated to group_trigger.mention_only")
}
// Verify backup was created with date suffix
entries, err := os.ReadDir(tmpDir)
if err != nil {
t.Fatalf("ReadDir: %v", err)
}
var hasBackup bool
for _, e := range entries {
if matched, _ := filepath.Match("config.json.20*.bak", e.Name()); matched {
hasBackup = true
break
}
}
if !hasBackup {
t.Error("expected backup file with date suffix to be created")
}
// Verify the saved config on disk now has version 2
saved, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("ReadFile saved config: %v", err)
}
var versionCheck struct {
Version int `json:"version"`
}
if err := json.Unmarshal(saved, &versionCheck); err != nil {
t.Fatalf("Unmarshal saved config: %v", err)
}
if versionCheck.Version != 2 {
t.Errorf("saved config version = %d, want 2", versionCheck.Version)
}
}
// TestLoadConfig_V1WithAPIKeysInferredEnabled verifies that V1 configs with
// API keys in the security file get Enabled=true after migration.
func TestLoadConfig_V1WithAPIKeysInferredEnabled(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
secPath := securityPath(configPath)
v1Config := `{
"version": 1,
"model_list": [
{"model_name": "gpt-4", "model": "openai/gpt-4"},
{"model_name": "claude", "model": "anthropic/claude"}
],
"gateway": {"host": "127.0.0.1", "port": 18790}
}`
securityConfig := `model_list:
gpt-4:0:
api_keys:
- "sk-gpt-key"
claude:0:
api_keys:
- "sk-claude-key"
`
if err := os.WriteFile(configPath, []byte(v1Config), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := os.WriteFile(secPath, []byte(securityConfig), 0o600); err != nil {
t.Fatalf("WriteFile security: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
for _, m := range cfg.ModelList {
if !m.Enabled {
t.Errorf("model %q with API key in security file should be enabled", m.ModelName)
}
}
}
// TestLoadConfig_V2DirectLoad verifies that V2 configs load directly without
// running any migration.
func TestLoadConfig_V2DirectLoad(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
v2Config := `{
"version": 2,
"model_list": [
{
"model_name": "gpt-4",
"model": "openai/gpt-4",
"enabled": true
},
{
"model_name": "claude",
"model": "anthropic/claude"
}
],
"gateway": {"host": "127.0.0.1", "port": 18790}
}`
if err := os.WriteFile(configPath, []byte(v2Config), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Version != 2 {
t.Errorf("Version = %d, want 2", cfg.Version)
}
gpt4, _ := cfg.GetModelConfig("gpt-4")
if !gpt4.Enabled {
t.Error("gpt-4 with explicit enabled=true should remain enabled")
}
claude, _ := cfg.GetModelConfig("claude")
if claude.Enabled {
t.Error("claude without enabled field should be false (no migration for V2)")
}
// No backup should be created for V2 load
entries, _ := os.ReadDir(tmpDir)
for _, e := range entries {
if matched, _ := filepath.Match("config.json.*.bak", e.Name()); matched {
t.Errorf("V2 load should not create backup, but found %q", e.Name())
}
}
}
// TestLoadConfig_V0MigrateProducesV2 verifies that V0→V2 migration produces
// correct Enabled fields and version.
func TestLoadConfig_V0MigrateProducesV2(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
v0Config := `{
"model_list": [
{
"model_name": "gpt-4",
"model": "openai/gpt-4",
"api_key": "sk-test"
},
{
"model_name": "claude",
"model": "anthropic/claude"
},
{
"model_name": "local-model",
"model": "vllm/custom-model"
}
],
"gateway": {"host": "127.0.0.1", "port": 18790}
}`
if err := os.WriteFile(configPath, []byte(v0Config), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.Version != CurrentVersion {
t.Errorf("Version = %d, want %d", cfg.Version, CurrentVersion)
}
// Check enabled status
modelEnabled := func(name string) bool {
m, err := cfg.GetModelConfig(name)
if err != nil {
return false
}
return m.Enabled
}
if !modelEnabled("gpt-4") {
t.Error("gpt-4 with API key from V0 should be enabled")
}
if modelEnabled("claude") {
t.Error("claude without API key from V0 should be disabled")
}
if !modelEnabled("local-model") {
t.Error("local-model from V0 should be enabled")
}
}
// TestLoadConfig_UnsupportedVersion verifies that unsupported versions return an error.
func TestLoadConfig_UnsupportedVersion(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
badConfig := `{"version": 99, "gateway": {"host": "127.0.0.1", "port": 18790}}`
if err := os.WriteFile(configPath, []byte(badConfig), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
_, err := LoadConfig(configPath)
if err == nil {
t.Fatal("LoadConfig should return error for unsupported version")
}
if !containsString(err.Error(), "unsupported config version") {
t.Errorf("error = %q, want 'unsupported config version'", err.Error())
}
}
func containsString(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View file

@ -345,7 +345,7 @@ func TestMergeAPIKeys(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := MergeAPIKeys(tt.apiKey, tt.apiKeys)
result := mergeAPIKeys(tt.apiKey, tt.apiKeys)
if len(result) != len(tt.expected) {
t.Fatalf("expected %d keys, got %d", len(tt.expected), len(result))
}

View file

@ -7,20 +7,16 @@ package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"sync"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/credential"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
@ -66,7 +62,6 @@ func saveSecurityConfig(securityPath string, sec *Config) error {
return fileutil.WriteFileAtomic(securityPath, buf.Bytes(), 0o600)
}
// SensitiveDataCache caches the compiled regex for filtering sensitive data.
// SensitiveDataCache caches the strings.Replacer for filtering sensitive data.
// Computed once on first access via sync.Once.
type SensitiveDataCache struct {
@ -178,234 +173,3 @@ func collectSensitive(v reflect.Value, values *[]string) {
}
}
}
const (
notHere = `"[NOT_HERE]"`
)
// SecureStrings is a slice of SecureString
type SecureStrings []*SecureString
// Values returns the decrypted/resolved values
func (s *SecureStrings) Values() []string {
if s == nil {
return nil
}
keys := make([]string, len(*s))
for i, k := range *s {
keys[i] = k.String()
}
return unique(keys)
}
func SimpleSecureStrings(val ...string) SecureStrings {
val = unique(val)
vv := make(SecureStrings, len(val))
for i, s := range val {
vv[i] = NewSecureString(s)
}
return vv
}
// unique returns a new slice with duplicate elements removed.
func unique[T comparable](input []T) []T {
m := make(map[T]struct{})
var result []T
for _, v := range input {
if _, ok := m[v]; !ok {
m[v] = struct{}{}
result = append(result, v)
}
}
return result
}
func (s SecureStrings) MarshalJSON() ([]byte, error) {
return []byte(notHere), nil
}
func (s *SecureStrings) UnmarshalJSON(value []byte) error {
if string(value) == notHere {
return nil
}
var v []*SecureString
err := json.Unmarshal(value, &v)
if err != nil {
return err
}
*s = v
return nil
}
// SecureString the string value that can be decrypted or resolved
//
//nolint:recvcheck
type SecureString struct {
resolved string // Decrypted/resolved value returned by String()
raw string // Persisted raw value (enc://, file://, or plaintext)
}
func callerFromYaml() bool {
_, file, _, ok := runtime.Caller(2)
if ok {
d := filepath.Dir(file)
// check the caller is from yaml.v
if !strings.Contains(d, "yaml.v") {
return true
}
}
return false
}
// IsZero returns true if the SecureString is empty
// if caller not yaml, just return true for prevent marshal this field
func (s SecureString) IsZero() bool {
if callerFromYaml() {
return true
}
return s.resolved == ""
}
func NewSecureString(value string) *SecureString {
s := &SecureString{}
if err := s.fromRaw(value); err != nil {
logger.Warn(fmt.Sprintf("NewSecureString.fromRaw error: %s", err))
}
return s
}
func (s *SecureString) String() string {
if s == nil {
return ""
}
return s.resolved
}
func (s *SecureString) Set(value string) *SecureString {
s.resolved = value
s.raw = ""
return s
}
func (s SecureString) MarshalJSON() ([]byte, error) {
return []byte(notHere), nil
}
func (s *SecureString) UnmarshalJSON(value []byte) error {
if string(value) == notHere {
return nil
}
var v string
if err := json.Unmarshal(value, &v); err != nil {
return err
}
return s.fromRaw(v)
}
func (s SecureString) MarshalYAML() (any, error) {
// Preserve raw value if it is already a reference (enc:// or file://)
if strings.HasPrefix(s.raw, credential.EncScheme) || strings.HasPrefix(s.raw, credential.FileScheme) {
return s.raw, nil
}
// If resolved is a reference format (e.g. set via Set), copy back to raw
if strings.HasPrefix(s.resolved, credential.EncScheme) || strings.HasPrefix(s.resolved, credential.FileScheme) {
s.raw = s.resolved
return s.raw, nil
}
// Try to encrypt the resolved value
if passphrase := credential.PassphraseProvider(); passphrase != "" {
encrypted, err := credential.Encrypt(passphrase, "", s.resolved)
if err != nil {
logger.Errorf("Encrypt error: %v", err)
return nil, err
}
s.raw = encrypted
} else {
s.raw = s.resolved
}
return s.raw, nil
}
func (s *SecureString) UnmarshalYAML(value *yaml.Node) error {
return s.fromRaw(value.Value)
}
func (s *SecureString) fromRaw(v string) error {
s.raw = v
vv, err := resolveKey(v)
if err != nil {
return err
}
s.resolved = vv
return nil
}
var (
secResolverMu sync.RWMutex
secResolver *credential.Resolver
)
func updateResolver(path string) {
secResolverMu.Lock()
defer secResolverMu.Unlock()
secResolver = credential.NewResolver(path)
}
func resolveKey(v string) (string, error) {
secResolverMu.RLock()
resolver := secResolver
secResolverMu.RUnlock()
if resolver == nil {
resolver = credential.NewResolver("")
}
if strings.HasPrefix(v, "enc://") || strings.HasPrefix(v, "file://") {
decrypted, err := resolver.Resolve(v)
if err != nil {
logger.Errorf("Resolve error: %v", err)
return "", err
}
return decrypted, nil
}
return v, nil
}
func (s *SecureString) UnmarshalText(text []byte) error {
v := string(text)
return s.fromRaw(v)
}
type SecureModelList []*ModelConfig
func (v *SecureModelList) UnmarshalYAML(value *yaml.Node) error {
mm := make(map[string]*ModelConfig)
if err := value.Decode(&mm); err != nil {
logger.Errorf("Decode error: %v", err)
return err
}
nameList := toNameIndex(*v)
for i, m := range *v {
sec := mm[nameList[i]]
if sec == nil {
sec = mm[m.ModelName]
}
if sec != nil {
m.APIKeys = sec.APIKeys
}
}
return nil
}
func (v SecureModelList) MarshalYAML() (any, error) {
type onlySecureData struct {
APIKeys SecureStrings `yaml:"api_keys,omitempty"`
}
mm := make(map[string]onlySecureData)
nameList := toNameIndex(v)
for i, m := range v {
mm[nameList[i]] = onlySecureData{
APIKeys: m.APIKeys,
}
}
return mm, nil
}

View file

@ -15,8 +15,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/sipeed/picoclaw/pkg/credential"
)
func TestSecurityConfig(t *testing.T) {
@ -227,134 +225,3 @@ skills:
assert.Equal(t, "abc", cfg2.Tools.Web.Brave.APIKeys[1].raw)
})
}
func TestLoadSecurityValue(t *testing.T) {
type valueStruct struct {
Url string `json:"url,omitempty" yaml:"-"`
Token *SecureString `json:"token,omitempty" yaml:"token,omitempty" env:"PICO_TOKEN"`
ApiKeys SecureStrings `json:"api_keys,omitempty" yaml:"api_keys,omitempty" env:"PICO_API_KEYS"`
}
type testStruct struct {
Pico *valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
}
v1 := &testStruct{
Pico: &valueStruct{
Url: "https://example.com",
Token: NewSecureString("token1"),
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
},
}
bytes, err := yaml.Marshal(v1)
assert.NoError(t, err)
jsonBytes, err := json.Marshal(v1)
assert.NoError(t, err)
const want = `pico:
token: token1
api_keys:
- api-key1
- api-key2
`
const jsonPost = `{"pico":{"url":"https://example.com","token":"token0"}}`
v0 := &testStruct{}
err = json.Unmarshal([]byte(jsonPost), v0)
assert.NoError(t, err)
assert.Equal(t, "https://example.com", v0.Pico.Url)
assert.Equal(t, "token0", v0.Pico.Token.String())
const jsonWant = `{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}`
assert.Equal(t, want, string(bytes))
assert.Equal(t, jsonWant, string(jsonBytes))
v2 := &testStruct{}
err = json.Unmarshal(jsonBytes, v2)
assert.NoError(t, err)
err = yaml.Unmarshal(bytes, v2)
assert.NoError(t, err)
assert.Equal(t, "https://example.com", v2.Pico.Url)
if v2.Pico.Token != nil {
assert.Equal(t, "token1", v2.Pico.Token.String())
assert.Equal(t, "token1", v2.Pico.Token.raw)
}
v2.Pico.Token = NewSecureString("token1")
v2.Pico.Token.raw = "abc"
err = yaml.Unmarshal(bytes, v2)
assert.NoError(t, err)
assert.Equal(t, "token1", v2.Pico.Token.raw)
os.Setenv("PICO_TOKEN", "token_env")
err = env.Parse(v2)
assert.NoError(t, err)
assert.NotNil(t, v2.Pico.Token)
assert.Equal(t, "token1", v2.Pico.Token.String())
v3 := &testStruct{Pico: &valueStruct{}}
err = env.Parse(v3)
assert.NoError(t, err)
if v3.Pico.Token != nil {
assert.Equal(t, "token_env", v3.Pico.Token.String())
}
type toolsStruct struct {
Pico valueStruct `json:"pico,omitempty" yaml:"pico,omitempty"`
}
type testStruct2 struct {
Tools toolsStruct `json:"tools,omitempty" yaml:",inline"`
}
v4 := &testStruct2{
Tools: toolsStruct{
Pico: valueStruct{
Url: "https://example.com",
Token: NewSecureString("token1"),
ApiKeys: SecureStrings{NewSecureString("api-key1"), NewSecureString("api-key2")},
},
},
}
bytes, err = yaml.Marshal(v4)
assert.NoError(t, err)
assert.Equal(t, want, string(bytes))
jsonBytes, err = json.Marshal(v4)
assert.NoError(t, err)
assert.Equal(
t,
`{"tools":{"pico":{"url":"https://example.com","token":"[NOT_HERE]","api_keys":"[NOT_HERE]"}}}`,
string(jsonBytes),
)
v5 := &testStruct2{}
err = json.Unmarshal(jsonBytes, v5)
assert.NoError(t, err)
assert.Equal(t, "https://example.com", v5.Tools.Pico.Url)
err = yaml.Unmarshal(bytes, v5)
assert.NoError(t, err)
assert.NotNil(t, v5.Tools.Pico.Token)
assert.Equal(t, "token1", v5.Tools.Pico.Token.raw)
dir := t.TempDir()
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
if err = os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
const passphrase = "test-passphrase-32bytes-long-ok!"
t.Setenv(credential.SSHKeyPathEnvVar, sshKeyPath)
t.Setenv(credential.PassphraseEnvVar, passphrase)
v5.Tools.Pico.Token.Set("newtoken1")
v5.Tools.Pico.ApiKeys[0].Set("newapi-key1")
bytes, err = yaml.Marshal(v5)
assert.NoError(t, err)
t.Logf("yaml: %s", string(bytes))
v6 := &testStruct2{}
err = yaml.Unmarshal(bytes, v6)
assert.NoError(t, err)
assert.NotNil(t, v6.Tools.Pico.Token)
assert.Equal(t, "newtoken1", v6.Tools.Pico.Token.String())
}

View file

@ -25,10 +25,8 @@ type CronSchedule struct {
type CronPayload struct {
Kind string `json:"kind"`
Type string `json:"type"`
Message string `json:"message"`
Command string `json:"command,omitempty"`
Deliver bool `json:"deliver"`
Channel string `json:"channel,omitempty"`
To string `json:"to,omitempty"`
}
@ -410,7 +408,6 @@ func (cs *CronService) AddJob(
name string,
schedule CronSchedule,
message string,
deliver bool,
channel, to string,
) (*CronJob, error) {
cs.mu.Lock()
@ -429,7 +426,6 @@ func (cs *CronService) AddJob(
Payload: CronPayload{
Kind: "agent_turn",
Message: message,
Deliver: deliver,
Channel: channel,
To: to,
},

View file

@ -20,7 +20,7 @@ func TestSaveStore_FilePermissions(t *testing.T) {
cs := NewCronService(storePath, nil)
_, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct")
_, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", "cli", "direct")
if err != nil {
t.Fatalf("AddJob failed: %v", err)
}
@ -52,7 +52,7 @@ func TestCronService_CRUD(t *testing.T) {
// Test AddJob
at := time.Now().Add(time.Hour).UnixMilli()
job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", true, "ch", "to")
job, err := cs.AddJob("Task1", CronSchedule{Kind: "at", AtMS: &at}, "msg", "ch", "to")
if err != nil || job.ID == "" {
t.Fatalf("AddJob failed: %v", err)
}
@ -134,7 +134,7 @@ func TestCronService_ExecutionFlow(t *testing.T) {
// Add a job then runs 100ms from now
target := time.Now().Add(100 * time.Millisecond).UnixMilli()
job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", false, "", "")
job, _ := cs.AddJob("FastJob", CronSchedule{Kind: "at", AtMS: &target}, "", "", "")
// Check for job execution with a timeout
success := false
@ -167,7 +167,7 @@ func TestCronService_PersistenceIntegrity(t *testing.T) {
// write a job and persist
cs1 := NewCronService(tmpFile, nil)
at := int64(2000000000000)
cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", true, "ch1", "")
cs1.AddJob("PersistMe", CronSchedule{Kind: "at", AtMS: &at}, "payload", "ch1", "")
// check file exists
if _, err := os.Stat(tmpFile); os.IsNotExist(err) {
@ -213,7 +213,7 @@ func TestCronService_ConcurrentAccess(t *testing.T) {
defer wg.Done()
for j := range iterations {
at := time.Now().Add(time.Hour).UnixMilli()
cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", false, "", "")
cs.AddJob(fmt.Sprintf("Job-%d-%d", id, j), CronSchedule{Kind: "at", AtMS: &at}, "", "", "")
time.Sleep(100 * time.Microsecond)
}
}(i)

View file

@ -94,13 +94,17 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
defer panicFunc()
if err = logger.EnableFileLogging(filepath.Join(homePath, logPath, logFile)); err != nil {
panic(fmt.Sprintf("error enabling file logging: %v", err))
logger.Fatal(fmt.Sprintf("error enabling file logging: %v", err))
}
defer logger.DisableFileLogging()
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("error loading config: %w", err)
logger.Fatalf("error loading config: %v", err)
}
if err = preCheckConfig(cfg); err != nil {
logger.Fatalf("config pre-check failed: %v", err)
}
logger.SetLevelFromString(cfg.Gateway.LogLevel)
@ -224,6 +228,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
}
}
func preCheckConfig(cfg *config.Config) error {
if cfg.Gateway.Port <= 0 || cfg.Gateway.Port > 65535 {
return fmt.Errorf("invalid gateway port: %d, port must be between 1 and 65535", cfg.Gateway.Port)
}
return nil
}
func executeReload(
ctx context.Context,
agentLoop *agent.AgentLoop,

View file

@ -11,6 +11,7 @@ import (
"sync"
"github.com/rs/zerolog"
"golang.org/x/term"
)
type LogLevel = zerolog.Level
@ -21,6 +22,8 @@ const (
WARN = zerolog.WarnLevel
ERROR = zerolog.ErrorLevel
FATAL = zerolog.FatalLevel
Component = "component"
)
var (
@ -32,28 +35,47 @@ var (
FATAL: "FATAL",
}
currentLevel = INFO
logger zerolog.Logger
fileLogger zerolog.Logger
logFile *os.File
once sync.Once
mu sync.RWMutex
currentLevel = INFO
logger zerolog.Logger
logFile *os.File
once sync.Once
mu sync.RWMutex
writers []io.Writer
consoleWriter zerolog.ConsoleWriter
)
func init() {
once.Do(func() {
zerolog.SetGlobalLevel(zerolog.InfoLevel)
consoleWriter := zerolog.ConsoleWriter{
isTTY := term.IsTerminal(int(os.Stdout.Fd()))
consoleWriter = zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: "15:04:05", // TODO: make it configurable???
// Custom formatter to handle multiline strings and JSON objects
FormatFieldValue: formatFieldValue,
PartsOrder: []string{
zerolog.TimestampFieldName,
zerolog.LevelFieldName,
Component,
zerolog.CallerFieldName,
zerolog.MessageFieldName,
},
FieldsExclude: []string{Component},
FormatPrepare: func(fields map[string]any) error {
if isTTY {
fields[Component] = fmt.Sprintf("\x1b[33m%v\x1b[0m", fields[Component])
}
return nil
},
NoColor: !isTTY,
}
logger = zerolog.New(consoleWriter).With().Timestamp().Caller().Logger()
fileLogger = zerolog.Logger{}
writers = append(writers, consoleWriter)
logger = zerolog.New(io.MultiWriter(writers...)).With().Timestamp().Caller().Logger()
})
}
@ -104,7 +126,15 @@ func SetConsoleLevel(level LogLevel) {
func DisableConsole() {
mu.Lock()
defer mu.Unlock()
logger = zerolog.New(io.Discard).With().Timestamp().Caller().Logger()
writers[0] = io.Discard
logger = logger.Output(io.MultiWriter(writers...))
}
func EnableConsole() {
mu.Lock()
defer mu.Unlock()
writers[0] = consoleWriter
logger = logger.Output(io.MultiWriter(writers...))
}
func GetLevel() LogLevel {
@ -162,7 +192,14 @@ func EnableFileLogging(filePath string) error {
}
logFile = newFile
fileLogger = zerolog.New(logFile).With().Timestamp().Caller().Logger()
if len(writers) != 1 {
return fmt.Errorf("failed to configure file logging: %w", err)
}
writers = append(writers, logFile)
logger = logger.Output(io.MultiWriter(writers...))
return nil
}
@ -174,7 +211,10 @@ func DisableFileLogging() {
logFile.Close()
logFile = nil
}
fileLogger = zerolog.Logger{}
if len(writers) > 1 {
writers = writers[:1]
logger = logger.Output(io.MultiWriter(writers...))
}
}
func ConfigureFromEnv() {
@ -193,7 +233,28 @@ func ConfigureFromEnv() {
}
}
func getCallerSkip() int {
const (
locUnknown = "<unknown>"
)
func getPackageNameFromFile(filePath string) string {
dir := filepath.Dir(filePath)
importPath := filepath.ToSlash(dir)
parts := strings.Split(importPath, "/")
if len(parts) == 0 {
return locUnknown
}
pkg := parts[len(parts)-1]
if pkg == "." {
return "<main>"
}
return pkg
}
func getCallerSkip() (int, string) {
for i := 2; i < 15; i++ {
pc, file, _, ok := runtime.Caller(i)
if !ok {
@ -217,10 +278,10 @@ func getCallerSkip() int {
continue
}
return i - 1
return i - 1, getPackageNameFromFile(file)
}
return 3
return 3, locUnknown
}
//nolint:zerologlint
@ -246,33 +307,19 @@ func logMessage(level LogLevel, component string, message string, fields map[str
return
}
skip := getCallerSkip()
skip, pkg := getCallerSkip()
event := getEvent(logger, level)
if component != "" {
event.Str("component", component)
if component == "" {
component = pkg
}
event.Str(Component, component)
appendFields(event, fields)
event.CallerSkipFrame(skip).Msg(message)
// Also log to file if enabled
if fileLogger.GetLevel() != zerolog.NoLevel {
fileEvent := getEvent(fileLogger, level)
if component != "" {
fileEvent.Str("component", component)
}
// fileEvent.Str("caller", fmt.Sprintf("%s:%d (%s)", callerFile, callerLine, callerFunc))
appendFields(fileEvent, fields)
fileEvent.CallerSkipFrame(skip).Msg(message)
}
if level == FATAL {
os.Exit(1)
}
}
func appendFields(event *zerolog.Event, fields map[string]any) {

View file

@ -406,3 +406,28 @@ func TestConfigureFromEnvNoEnv(t *testing.T) {
os.Unsetenv("PICOCLAW_LOG_FILE")
ConfigureFromEnv()
}
func TestGetPackageNameFromFile(t *testing.T) {
tests := []struct {
name string
path string
want string
}{
{"normal package path", "/home/user/project/pkg/logger/logger.go", "logger"},
{"nested package", "/home/user/project/internal/service/auth/handler.go", "auth"},
{"cmd package", "/home/user/project/cmd/server/main.go", "server"},
{"project root returns main", "./main.go", "<main>"},
{"single dot returns main", ".", "<main>"},
{"single directory", "mypkg/file.go", "mypkg"},
{"deep nesting", "/a/b/c/d/e/f.go", "e"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getPackageNameFromFile(tt.path)
if got != tt.want {
t.Errorf("getPackageNameFromFile(%q) = %q, want %q", tt.path, got, tt.want)
}
})
}
}

View file

@ -21,8 +21,10 @@ type Tool interface {
type toolCtxKey struct{ name string }
var (
ctxKeyChannel = &toolCtxKey{"channel"}
ctxKeyChatID = &toolCtxKey{"chatID"}
ctxKeyChannel = &toolCtxKey{"channel"}
ctxKeyChatID = &toolCtxKey{"chatID"}
ctxKeyMessageID = &toolCtxKey{"messageID"}
ctxKeyReplyToMessageID = &toolCtxKey{"replyToMessageID"}
)
// WithToolContext returns a child context carrying channel and chatID.
@ -32,6 +34,23 @@ func WithToolContext(ctx context.Context, channel, chatID string) context.Contex
return ctx
}
// WithToolMessageContext returns a child context carrying inbound message IDs.
func WithToolMessageContext(ctx context.Context, messageID, replyToMessageID string) context.Context {
ctx = context.WithValue(ctx, ctxKeyMessageID, messageID)
ctx = context.WithValue(ctx, ctxKeyReplyToMessageID, replyToMessageID)
return ctx
}
// WithToolInboundContext returns a child context carrying channel/chat and inbound IDs.
func WithToolInboundContext(
ctx context.Context,
channel, chatID, messageID, replyToMessageID string,
) context.Context {
ctx = WithToolContext(ctx, channel, chatID)
ctx = WithToolMessageContext(ctx, messageID, replyToMessageID)
return ctx
}
// ToolChannel extracts the channel from ctx, or "" if unset.
func ToolChannel(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyChannel).(string)
@ -44,6 +63,18 @@ func ToolChatID(ctx context.Context) string {
return v
}
// ToolMessageID extracts the current inbound message ID from ctx, or "" if unset.
func ToolMessageID(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyMessageID).(string)
return v
}
// ToolReplyToMessageID extracts the current inbound reply target from ctx, or "" if unset.
func ToolReplyToMessageID(ctx context.Context) string {
v, _ := ctx.Value(ctxKeyReplyToMessageID).(string)
return v
}
// AsyncCallback is a function type that async tools use to notify completion.
// When an async tool finishes its work, it calls this callback with the result.
//

View file

@ -92,7 +92,7 @@ func (t *CronTool) Parameters() map[string]any {
},
"command": map[string]any{
"type": "string",
"description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message. 'deliver' will be forced to false for commands.",
"description": "Optional: Shell command to execute directly (e.g., 'df -h'). If set, the agent will run this command and report output instead of just showing the message.",
},
"command_confirm": map[string]any{
"type": "boolean",
@ -114,15 +114,6 @@ func (t *CronTool) Parameters() map[string]any {
"type": "string",
"description": "Job ID (for remove/enable/disable)",
},
"type": map[string]any{
"type": "string",
"enum": []string{"message", "directive"},
"description": "Message generation strategy. 'message' (default): content is sent directly as-is. 'directive': content is treated as instructions for an AI agent to execute before delivery.",
},
"deliver": map[string]any{
"type": "boolean",
"description": "If true, send message directly to channel. If false, let agent process message (for complex tasks). Default: false",
},
},
"required": []string{"action"},
}
@ -199,18 +190,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
return ErrorResult("one of at_seconds, every_seconds, or cron_expr is required")
}
// Read deliver parameter, default to false so scheduled tasks execute through the agent
deliver := false
if d, ok := args["deliver"].(bool); ok {
deliver = d
}
// Validate type parameter (server-side whitelist, not just LLM schema hint)
msgType, _ := args["type"].(string)
if msgType != "" && msgType != "message" && msgType != "directive" {
return ErrorResult(fmt.Sprintf("invalid type %q, must be 'message' or 'directive'", msgType))
}
// GHSA-pv8c-p6jf-3fpp: command scheduling requires internal channel. When
// allow_command is disabled, explicit confirmation is required as an override.
// Non-command reminders remain open to all channels.
@ -226,7 +205,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
if !t.allowCommand && !commandConfirm {
return ErrorResult("command_confirm=true is required when allow_command is disabled")
}
deliver = false
}
// Truncate message for job name (max 30 chars)
@ -236,7 +214,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
messagePreview,
schedule,
message,
deliver,
channel,
chatID,
)
@ -250,10 +227,6 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult
job.Payload.Command = command
needsUpdate = true
}
if msgType != "" {
job.Payload.Type = msgType
needsUpdate = true
}
if needsUpdate {
t.cronService.UpdateJob(job)
}
@ -369,40 +342,12 @@ func (t *CronTool) ExecuteJob(ctx context.Context, job *cron.CronJob) string {
return "ok"
}
// Determine message generation strategy
// Type="directive": treat message as instructions for AI agent to execute
// Type="" or "message" (default): static message content
isDirective := job.Payload.Type == "directive"
// If deliver=true and not directive, send message directly without agent processing
if job.Payload.Deliver && !isDirective {
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
t.msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
Content: job.Payload.Message,
})
return "ok"
}
// For deliver=false OR directive mode, process through agent
sessionKey := fmt.Sprintf("cron-%s", job.ID)
// Prepare the prompt based on type
prompt := job.Payload.Message
if isDirective {
// For directive type, prefix to clarify this is an instruction
prompt = fmt.Sprintf(
"Please execute the following directive and provide the result:\n\n%s",
job.Payload.Message,
)
}
// Call agent with the prepared prompt
// Call agent with the job message
response, err := t.executor.ProcessDirectWithChannel(
ctx,
prompt,
job.Payload.Message,
sessionKey,
channel,
chatID,

View file

@ -229,28 +229,6 @@ func TestCronTool_NonCommandJobAllowedFromRemoteChannel(t *testing.T) {
}
}
func TestCronTool_NonCommandJobDefaultsDeliverToFalse(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "send me a poem",
"at_seconds": float64(600),
})
if result.IsError {
t.Fatalf("expected non-command reminder to succeed, got: %s", result.ForLLM)
}
jobs := tool.cronService.ListJobs(false)
if len(jobs) != 1 {
t.Fatalf("expected 1 job, got %d", len(jobs))
}
if jobs[0].Payload.Deliver {
t.Fatal("expected deliver=false by default for non-command jobs")
}
}
func TestCronTool_ExecuteJobPublishesErrorWhenExecDisabled(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Tools.Exec.Enabled = false
@ -346,93 +324,6 @@ func TestCronTool_ExecuteJobSkipsWhenMessageToolAlreadySent(t *testing.T) {
}
}
func TestCronTool_ExecuteJobDirectiveAddsPromptPrefix(t *testing.T) {
executor := &stubJobExecutor{response: "directive result"}
tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
originalMsg := "check the weather and summarize"
job := &cron.CronJob{ID: "job-dir-1"}
job.Payload.Channel = "telegram"
job.Payload.To = "chat-1"
job.Payload.Message = originalMsg
job.Payload.Type = "directive"
if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
t.Fatalf("ExecuteJob() = %q, want ok", got)
}
wantPrompt := "Please execute the following directive and provide the result:\n\n" + originalMsg
if executor.lastPrompt != wantPrompt {
t.Fatalf("prompt = %q, want exact %q", executor.lastPrompt, wantPrompt)
}
if executor.publishedResp != "directive result" {
t.Fatalf("published response = %q, want %q", executor.publishedResp, "directive result")
}
}
func TestCronTool_ExecuteJobDirectiveWithDeliverRoutesToAgent(t *testing.T) {
executor := &stubJobExecutor{response: "agent processed"}
tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
job := &cron.CronJob{ID: "job-dir-deliver"}
job.Payload.Channel = "telegram"
job.Payload.To = "chat-1"
job.Payload.Message = "generate daily report"
job.Payload.Type = "directive"
job.Payload.Deliver = true
if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
t.Fatalf("ExecuteJob() = %q, want ok", got)
}
if executor.lastPrompt == "" {
t.Fatal("expected agent to be called for directive+deliver, but ProcessDirectWithChannel was not invoked")
}
if executor.publishedResp != "agent processed" {
t.Fatalf("published response = %q, want %q", executor.publishedResp, "agent processed")
}
// Verify no direct publish happened on the bus (agent path, not direct path)
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
select {
case msg := <-tool.msgBus.OutboundChan():
t.Fatalf("unexpected direct bus message: %+v", msg)
case <-ctx.Done():
// expected: no direct bus message
}
}
func TestCronTool_ExecuteJobDeliverMessageDirectlyToBus(t *testing.T) {
executor := &stubJobExecutor{response: "should not be called"}
tool := newTestCronToolWithExecutorAndConfig(t, executor, config.DefaultConfig())
job := &cron.CronJob{ID: "job-deliver"}
job.Payload.Channel = "telegram"
job.Payload.To = "chat-1"
job.Payload.Message = "hello world"
job.Payload.Deliver = true
if got := tool.ExecuteJob(context.Background(), job); got != "ok" {
t.Fatalf("ExecuteJob() = %q, want ok", got)
}
if executor.lastPrompt != "" {
t.Fatal("expected agent NOT to be invoked for deliver=true message type")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
select {
case msg := <-tool.msgBus.OutboundChan():
if msg.Content != "hello world" {
t.Fatalf("bus content = %q, want %q", msg.Content, "hello world")
}
case <-ctx.Done():
t.Fatal("timeout waiting for direct bus message")
}
}
func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
executor := &stubJobExecutor{
response: "this response must not be published",
@ -454,43 +345,3 @@ func TestCronTool_ExecuteJobReturnsErrorWithoutPublish(t *testing.T) {
t.Fatalf("unexpected publish on error path: %q", executor.publishedResp)
}
}
func TestCronTool_AddJobRejectsInvalidType(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "cli", "direct")
result := tool.Execute(ctx, map[string]any{
"action": "add",
"message": "test",
"at_seconds": float64(60),
"type": "invalid_type",
})
if !result.IsError {
t.Fatal("expected error for invalid type parameter")
}
if !strings.Contains(result.ForLLM, "invalid type") {
t.Errorf("expected 'invalid type' error, got: %s", result.ForLLM)
}
}
func TestCronTool_AddJobAcceptsValidTypes(t *testing.T) {
for _, msgType := range []string{"", "message", "directive"} {
t.Run("type="+msgType, func(t *testing.T) {
tool := newTestCronTool(t)
ctx := WithToolContext(context.Background(), "cli", "direct")
args := map[string]any{
"action": "add",
"message": "test",
"at_seconds": float64(60),
}
if msgType != "" {
args["type"] = msgType
}
result := tool.Execute(ctx, args)
if result.IsError {
t.Fatalf("expected valid type %q to succeed, got: %s", msgType, result.ForLLM)
}
})
}
}

View file

@ -6,7 +6,7 @@ import (
"sync/atomic"
)
type SendCallback func(channel, chatID, content string) error
type SendCallback func(channel, chatID, content, replyToMessageID string) error
type MessageTool struct {
sendCallback SendCallback
@ -41,6 +41,10 @@ func (t *MessageTool) Parameters() map[string]any {
"type": "string",
"description": "Optional: target chat/user ID",
},
"reply_to_message_id": map[string]any{
"type": "string",
"description": "Optional: reply target message ID for channels that support threaded replies",
},
},
"required": []string{"content"},
}
@ -69,6 +73,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
channel, _ := args["channel"].(string)
chatID, _ := args["chat_id"].(string)
replyToMessageID, _ := args["reply_to_message_id"].(string)
if channel == "" {
channel = ToolChannel(ctx)
@ -85,7 +90,7 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes
return &ToolResult{ForLLM: "Message sending not configured", IsError: true}
}
if err := t.sendCallback(channel, chatID, content); err != nil {
if err := t.sendCallback(channel, chatID, content, replyToMessageID); err != nil {
return &ToolResult{
ForLLM: fmt.Sprintf("sending message: %v", err),
IsError: true,

View file

@ -10,7 +10,7 @@ func TestMessageTool_Execute_Success(t *testing.T) {
tool := NewMessageTool()
var sentChannel, sentChatID, sentContent string
tool.SetSendCallback(func(channel, chatID, content string) error {
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
sentChannel = channel
sentChatID = chatID
sentContent = content
@ -61,7 +61,7 @@ func TestMessageTool_Execute_WithCustomChannel(t *testing.T) {
tool := NewMessageTool()
var sentChannel, sentChatID string
tool.SetSendCallback(func(channel, chatID, content string) error {
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
sentChannel = channel
sentChatID = chatID
return nil
@ -96,7 +96,7 @@ func TestMessageTool_Execute_SendFailure(t *testing.T) {
tool := NewMessageTool()
sendErr := errors.New("network error")
tool.SetSendCallback(func(channel, chatID, content string) error {
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
return sendErr
})
@ -149,7 +149,7 @@ func TestMessageTool_Execute_NoTargetChannel(t *testing.T) {
tool := NewMessageTool()
// No WithToolContext — channel/chatID are empty
tool.SetSendCallback(func(channel, chatID, content string) error {
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
return nil
})
@ -251,4 +251,37 @@ func TestMessageTool_Parameters(t *testing.T) {
if chatIDProp["type"] != "string" {
t.Error("Expected chat_id type to be 'string'")
}
// Check reply_to_message_id property (optional)
replyToProp, ok := props["reply_to_message_id"].(map[string]any)
if !ok {
t.Error("Expected 'reply_to_message_id' property")
}
if replyToProp["type"] != "string" {
t.Error("Expected reply_to_message_id type to be 'string'")
}
}
func TestMessageTool_Execute_WithReplyToMessageID(t *testing.T) {
tool := NewMessageTool()
var sentReplyTo string
tool.SetSendCallback(func(channel, chatID, content, replyToMessageID string) error {
sentReplyTo = replyToMessageID
return nil
})
ctx := WithToolContext(context.Background(), "test-channel", "test-chat-id")
args := map[string]any{
"content": "Reply test",
"reply_to_message_id": "msg-123",
}
result := tool.Execute(ctx, args)
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if sentReplyTo != "msg-123" {
t.Fatalf("expected reply_to_message_id msg-123, got %q", sentReplyTo)
}
}

87
pkg/tools/reaction.go Normal file
View file

@ -0,0 +1,87 @@
package tools
import (
"context"
"fmt"
)
type ReactionCallback func(ctx context.Context, channel, chatID, messageID string) error
type ReactionTool struct {
reactionCallback ReactionCallback
}
func NewReactionTool() *ReactionTool {
return &ReactionTool{}
}
func (t *ReactionTool) Name() string {
return "reaction"
}
func (t *ReactionTool) Description() string {
return "Add a reaction to a message. Defaults to the current inbound message when message_id is omitted."
}
func (t *ReactionTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"message_id": map[string]any{
"type": "string",
"description": "Optional: target message ID; defaults to the current inbound message",
},
"channel": map[string]any{
"type": "string",
"description": "Optional: target channel (telegram, whatsapp, etc.)",
},
"chat_id": map[string]any{
"type": "string",
"description": "Optional: target chat/user ID",
},
},
}
}
func (t *ReactionTool) SetReactionCallback(callback ReactionCallback) {
t.reactionCallback = callback
}
func (t *ReactionTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
channel, _ := args["channel"].(string)
chatID, _ := args["chat_id"].(string)
messageID, _ := args["message_id"].(string)
if channel == "" {
channel = ToolChannel(ctx)
}
if chatID == "" {
chatID = ToolChatID(ctx)
}
if messageID == "" {
messageID = ToolMessageID(ctx)
}
if channel == "" || chatID == "" {
return &ToolResult{ForLLM: "No target channel/chat specified", IsError: true}
}
if messageID == "" {
return &ToolResult{ForLLM: "message_id is required", IsError: true}
}
if t.reactionCallback == nil {
return &ToolResult{ForLLM: "Reaction not configured", IsError: true}
}
if err := t.reactionCallback(ctx, channel, chatID, messageID); err != nil {
return &ToolResult{
ForLLM: fmt.Sprintf("adding reaction: %v", err),
IsError: true,
Err: err,
}
}
return &ToolResult{
ForLLM: fmt.Sprintf("Reaction added to %s:%s message %s", channel, chatID, messageID),
Silent: true,
}
}

View file

@ -0,0 +1,96 @@
package tools
import (
"context"
"errors"
"testing"
)
func TestReactionTool_Execute_UsesContextMessageIDByDefault(t *testing.T) {
tool := NewReactionTool()
var gotChannel, gotChatID, gotMessageID string
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
gotChannel = channel
gotChatID = chatID
gotMessageID = messageID
return nil
})
ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "")
result := tool.Execute(ctx, map[string]any{})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if gotChannel != "telegram" || gotChatID != "chat-1" || gotMessageID != "msg-100" {
t.Fatalf("unexpected callback args: channel=%q chatID=%q messageID=%q", gotChannel, gotChatID, gotMessageID)
}
}
func TestReactionTool_Execute_AllowsExplicitMessageIDOverride(t *testing.T) {
tool := NewReactionTool()
var gotMessageID string
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
gotMessageID = messageID
return nil
})
ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-context", "")
result := tool.Execute(ctx, map[string]any{"message_id": "msg-explicit"})
if result.IsError {
t.Fatalf("expected success, got error: %s", result.ForLLM)
}
if gotMessageID != "msg-explicit" {
t.Fatalf("expected explicit message id, got %q", gotMessageID)
}
}
func TestReactionTool_Execute_MissingMessageID(t *testing.T) {
tool := NewReactionTool()
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error { return nil })
ctx := WithToolContext(context.Background(), "telegram", "chat-1")
result := tool.Execute(ctx, map[string]any{})
if !result.IsError {
t.Fatal("expected error")
}
if result.ForLLM != "message_id is required" {
t.Fatalf("unexpected error message: %q", result.ForLLM)
}
}
func TestReactionTool_Execute_CallbackError(t *testing.T) {
tool := NewReactionTool()
tool.SetReactionCallback(func(ctx context.Context, channel, chatID, messageID string) error {
return errors.New("unsupported")
})
ctx := WithToolInboundContext(context.Background(), "telegram", "chat-1", "msg-100", "")
result := tool.Execute(ctx, map[string]any{})
if !result.IsError {
t.Fatal("expected error")
}
if result.Err == nil {
t.Fatal("expected wrapped error")
}
}
func TestReactionTool_Parameters(t *testing.T) {
tool := NewReactionTool()
params := tool.Parameters()
props, ok := params["properties"].(map[string]any)
if !ok {
t.Fatal("expected properties map")
}
if _, ok := props["message_id"]; !ok {
t.Fatal("expected message_id parameter")
}
if _, ok := props["channel"]; !ok {
t.Fatal("expected channel parameter")
}
if _, ok := props["chat_id"]; !ok {
t.Fatal("expected chat_id parameter")
}
}

View file

@ -190,6 +190,33 @@ func TestToolRegistry_ExecuteWithContext_EmptyContext(t *testing.T) {
}
}
func TestToolRegistry_ExecuteWithContext_PreservesMessageContext(t *testing.T) {
r := NewToolRegistry()
ct := &mockContextAwareTool{
mockRegistryTool: *newMockTool("ctx_tool", "needs context"),
}
r.Register(ct)
baseCtx := WithToolMessageContext(context.Background(), "msg-123", "msg-100")
r.ExecuteWithContext(baseCtx, "ctx_tool", nil, "telegram", "chat-42", nil)
if ct.lastCtx == nil {
t.Fatal("expected Execute to be called")
}
if got := ToolChannel(ct.lastCtx); got != "telegram" {
t.Errorf("expected channel 'telegram', got %q", got)
}
if got := ToolChatID(ct.lastCtx); got != "chat-42" {
t.Errorf("expected chatID 'chat-42', got %q", got)
}
if got := ToolMessageID(ct.lastCtx); got != "msg-123" {
t.Errorf("expected messageID 'msg-123', got %q", got)
}
if got := ToolReplyToMessageID(ct.lastCtx); got != "msg-100" {
t.Errorf("expected replyToMessageID 'msg-100', got %q", got)
}
}
func TestToolRegistry_ExecuteWithContext_AsyncCallback(t *testing.T) {
r := NewToolRegistry()
at := &mockAsyncRegistryTool{

View file

@ -29,18 +29,18 @@ const (
DefaultBM25B = 0.75
)
// BM25Engine is a query-time BM25 search engine over a generic corpus.
// BM25Engine is a BM25 search engine over a generic corpus.
// T is the document type; the caller supplies a TextFunc that extracts the
// searchable text from each document.
//
// The engine is stateless between queries: no caching, no invalidation logic.
// All indexing work is performed inside Search() on every call, making it
// safe to use on corpora that change frequently.
// The engine precomputes its index once at construction time and reuses it for
// subsequent searches. If the corpus content changes, construct a new engine.
type BM25Engine[T any] struct {
corpus []T
textFunc func(T) string
k1 float64
b float64
index *bm25Index
}
// BM25Option is a functional option to configure a BM25Engine.
@ -51,6 +51,17 @@ type bm25Config struct {
b float64
}
type bm25Index struct {
entries []bm25DocEntry
idf map[string]float32
docLenNorm []float32
posting map[string][]int32
}
type bm25DocEntry struct {
tf map[string]uint32
}
// WithK1 overrides the term-frequency saturation constant (default 1.2).
func WithK1(k1 float64) BM25Option {
return func(c *bm25Config) { c.k1 = k1 }
@ -74,12 +85,14 @@ func NewBM25Engine[T any](corpus []T, textFunc func(T) string, opts ...BM25Optio
for _, o := range opts {
o(&cfg)
}
return &BM25Engine[T]{
engine := &BM25Engine[T]{
corpus: corpus,
textFunc: textFunc,
k1: cfg.k1,
b: cfg.b,
}
engine.index = buildBM25Index(corpus, textFunc, cfg.k1, cfg.b)
return engine
}
// BM25Result is a single ranked result from a Search call.
@ -91,9 +104,8 @@ type BM25Result[T any] struct {
// Search ranks the corpus against query and returns the top-k results.
// Returns an empty slice (not nil) when there are no matches.
//
// Complexity: O(N×L) for indexing + O(|Q|×avgPostingLen) for scoring,
// where N = corpus size, L = average document length, Q = query terms.
// Top-k extraction uses a fixed-size min-heap: O(candidates × log k).
// Complexity: O(|Q|×avgPostingLen + candidates × log k) per search after the
// one-time indexing work performed by NewBM25Engine.
func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
if topK <= 0 {
return []BM25Result[T]{}
@ -104,78 +116,24 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
return []BM25Result[T]{}
}
N := len(e.corpus)
if N == 0 {
if len(e.corpus) == 0 || e.index == nil {
return []BM25Result[T]{}
}
// Step 1: build per-document tf + raw doc lengths
type docEntry struct {
tf map[string]uint32
rawLen int
}
entries := make([]docEntry, N)
df := make(map[string]int, 64)
totalLen := 0
for i, doc := range e.corpus {
tokens := bm25Tokenize(e.textFunc(doc))
totalLen += len(tokens)
tf := make(map[string]uint32, len(tokens))
for _, t := range tokens {
tf[t]++
}
// df: each term counts once per document (iterate the map, keys are unique)
for t := range tf {
df[t]++
}
entries[i] = docEntry{tf: tf, rawLen: len(tokens)}
}
avgDocLen := float64(totalLen) / float64(N)
// Step 2: pre-compute IDF and per-doc length normalization
// IDF (Robertson smoothing): log( (N - df(t) + 0.5) / (df(t) + 0.5) + 1 )
idf := make(map[string]float32, len(df))
for term, freq := range df {
idf[term] = float32(math.Log(
(float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
))
}
// docLenNorm[i] = k1 * (1 - b + b * |doc_i| / avgDocLen)
// Stored as float32 — sufficient precision for ranking.
docLenNorm := make([]float32, N)
for i, entry := range entries {
docLenNorm[i] = float32(e.k1 * (1 - e.b + e.b*float64(entry.rawLen)/avgDocLen))
}
// Step 3: build inverted index (posting lists)
// Iterate the tf map directly — map keys are already unique, no seen-set needed.
posting := make(map[string][]int32, len(df))
for i, entry := range entries {
for term := range entry.tf {
posting[term] = append(posting[term], int32(i))
}
}
// Step 4: score via posting lists
// Deduplicate query terms to avoid double-weighting the same term.
unique := bm25Dedupe(queryTerms)
scores := make(map[int32]float32)
for _, term := range unique {
termIDF, ok := idf[term]
termIDF, ok := e.index.idf[term]
if !ok {
continue // term not in vocabulary → zero contribution
}
for _, docID := range posting[term] {
freq := float32(entries[docID].tf[term])
for _, docID := range e.index.posting[term] {
freq := float32(e.index.entries[docID].tf[term])
// TF_norm = freq * (k1+1) / (freq + docLenNorm)
tfNorm := freq * float32(e.k1+1) / (freq + docLenNorm[docID])
tfNorm := freq * float32(e.k1+1) / (freq + e.index.docLenNorm[docID])
scores[docID] += termIDF * tfNorm
}
}
@ -212,6 +170,65 @@ func (e *BM25Engine[T]) Search(query string, topK int) []BM25Result[T] {
return out
}
func buildBM25Index[T any](corpus []T, textFunc func(T) string, k1, b float64) *bm25Index {
N := len(corpus)
if N == 0 {
return nil
}
entries := make([]bm25DocEntry, N)
rawLens := make([]int, N)
df := make(map[string]int, 64)
totalLen := 0
for i, doc := range corpus {
tokens := bm25Tokenize(textFunc(doc))
totalLen += len(tokens)
rawLens[i] = len(tokens)
tf := make(map[string]uint32, len(tokens))
for _, t := range tokens {
tf[t]++
}
for term := range tf {
df[term]++
}
entries[i] = bm25DocEntry{tf: tf}
}
avgDocLen := float64(totalLen) / float64(N)
if avgDocLen == 0 {
avgDocLen = 1
}
idf := make(map[string]float32, len(df))
for term, freq := range df {
idf[term] = float32(math.Log(
(float64(N)-float64(freq)+0.5)/(float64(freq)+0.5) + 1,
))
}
docLenNorm := make([]float32, N)
for i, rawLen := range rawLens {
docLenNorm[i] = float32(k1 * (1 - b + b*float64(rawLen)/avgDocLen))
}
posting := make(map[string][]int32, len(df))
for i, entry := range entries {
for term := range entry.tf {
posting[term] = append(posting[term], int32(i))
}
}
return &bm25Index{
entries: entries,
idf: idf,
docLenNorm: docLenNorm,
posting: posting,
}
}
// bm25Tokenize splits s into lowercase tokens, stripping edge punctuation.
func bm25Tokenize(s string) []string {
raw := strings.Fields(strings.ToLower(s))

View file

@ -1,7 +1,9 @@
package utils
import (
"fmt"
"reflect"
"strings"
"testing"
)
@ -173,3 +175,61 @@ func TestBM25Search_SortingStability(t *testing.T) {
}
}
}
func BenchmarkBM25Search_ReusedIndex(b *testing.B) {
corpus := benchmarkBM25Corpus(2000)
engine := NewBM25Engine(corpus, extractText)
query := "hardware gpio i2c sensor controller latency"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
results := engine.Search(query, 10)
if len(results) == 0 {
b.Fatal("expected non-empty results")
}
}
}
func BenchmarkBM25Search_RebuildEachTime(b *testing.B) {
corpus := benchmarkBM25Corpus(2000)
query := "hardware gpio i2c sensor controller latency"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
engine := NewBM25Engine(corpus, extractText)
results := engine.Search(query, 10)
if len(results) == 0 {
b.Fatal("expected non-empty results")
}
}
}
func benchmarkBM25Corpus(size int) []testDoc {
corpus := make([]testDoc, size)
topics := []string{
"hardware gpio pwm adc sensor controller latency throughput",
"telegram markdown parser message escape formatting bot command",
"jsonl memory session history storage append compact recovery",
"openai provider routing agent tool search registry hidden tools",
"i2c spi uart serial device bus address transfer clock",
}
for i := range corpus {
topic := topics[i%len(topics)]
corpus[i] = testDoc{
ID: i,
Text: fmt.Sprintf(
"doc %d %s repeated repeated %s variant-%d %s",
i,
topic,
topic,
i%17,
strings.Repeat("token ", (i%7)+1),
),
}
}
return corpus
}

View file

@ -1,12 +1,20 @@
.PHONY: dev dev-frontend dev-backend build test lint clean
.PHONY: dev dev-frontend dev-backend build build-frontend build-dev-picoclaw test lint clean
# Go variables
GO?=CGO_ENABLED=0 go
WEB_GO?=$(GO)
GOFLAGS?=-v -tags stdjson
GO_BUILD_TAGS?=goolm,stdjson
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
# Build variables
BUILD_DIR=build
OUTPUT?=$(BUILD_DIR)/picoclaw-launcher
FRONTEND_DIR=frontend
BACKEND_DIR=backend
BACKEND_DIST=$(BACKEND_DIR)/dist
PICOCLAW_BINARY_NAME=picoclaw
PICOCLAW_BINARY?=$(abspath ../build/$(PICOCLAW_BINARY_NAME))
LAUNCHER_GUI_LDFLAG=
# Version
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
@ -52,45 +60,63 @@ else ifeq ($(UNAME_S),Darwin)
else ifeq ($(UNAME_S),Windows)
PLATFORM=windows
ARCH=$(UNAME_M)
LDFLAGS=-H=windowsgui $(LDFLAGS)
PICOCLAW_BINARY_NAME=picoclaw.exe
LAUNCHER_GUI_LDFLAG=-H=windowsgui
else
PLATFORM=$(UNAME_S)
ARCH=$(UNAME_M)
endif
LAUNCHER_LDFLAGS=$(strip $(LAUNCHER_GUI_LDFLAG) $(LDFLAGS))
# Run both frontend and backend dev servers
dev:
@if [ ! -f $(BUILD_DIR)/picoclaw-launcher ] || [ ! -d backend/dist ]; then \
echo "Build artifacts not found, building..."; \
$(MAKE) build; \
dev: build-dev-picoclaw
@if [ ! -f "$(BACKEND_DIST)/index.html" ]; then \
echo "Embedded frontend not found, building..."; \
$(MAKE) build-frontend; \
fi
@echo "Starting backend and frontend dev servers..."
@$(MAKE) dev-backend & $(MAKE) dev-frontend
@$(MAKE) dev-backend BACKEND_ARGS='-no-browser' & $(MAKE) dev-frontend
# Start frontend dev server (Vite, with proxy to backend)
dev-frontend:
cd frontend && pnpm dev
cd $(FRONTEND_DIR) && pnpm dev
# Start backend dev server
dev-backend:
cd backend && ${WEB_GO} run -ldflags "$(LDFLAGS)" .
cd $(BACKEND_DIR) && PICOCLAW_BINARY="$(PICOCLAW_BINARY)" ${WEB_GO} run -ldflags "$(LAUNCHER_LDFLAGS)" . $(BACKEND_ARGS)
# Build frontend and embed into Go binary
build:
cd frontend && pnpm build:backend
${WEB_GO} build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/picoclaw-launcher ./backend/
build: build-frontend
@mkdir -p "$$(dirname "$(OUTPUT)")"
${WEB_GO} build $(GOFLAGS) -ldflags "$(LAUNCHER_LDFLAGS)" -o "$(OUTPUT)" ./$(BACKEND_DIR)/
build-frontend:
@if [ ! -d $(FRONTEND_DIR)/node_modules ] || \
[ $(FRONTEND_DIR)/package.json -nt $(FRONTEND_DIR)/node_modules ] || \
[ $(FRONTEND_DIR)/pnpm-lock.yaml -nt $(FRONTEND_DIR)/node_modules ]; then \
echo "Installing frontend dependencies..."; \
cd $(FRONTEND_DIR) && pnpm install --frozen-lockfile; \
fi
@echo "Building frontend..."
@cd $(FRONTEND_DIR) && pnpm build:backend
build-dev-picoclaw:
@echo "Building picoclaw for launcher development..."
@mkdir -p "$$(dirname "$(PICOCLAW_BINARY)")"
@$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o "$(PICOCLAW_BINARY)" ../cmd/picoclaw
# Run all tests
test:
cd backend && ${WEB_GO} test ./...
cd frontend && pnpm lint
cd $(BACKEND_DIR) && ${WEB_GO} test ./...
cd $(FRONTEND_DIR) && pnpm lint
# Lint and format
lint:
cd backend && ${WEB_GO} vet ./...
cd frontend && pnpm check
cd $(BACKEND_DIR) && ${WEB_GO} vet ./...
cd $(FRONTEND_DIR) && pnpm check
# Clean build artifacts
clean:
rm -rf frontend/dist backend/dist $(BUILD_DIR)
mkdir -p backend/dist && touch backend/dist/.gitkeep
rm -rf $(FRONTEND_DIR)/dist $(BACKEND_DIST) $(BUILD_DIR)
node $(FRONTEND_DIR)/scripts/ensure-backend-gitkeep.cjs

View file

@ -1,51 +1,383 @@
# Picoclaw Web
# PicoClaw Web
This directory contains the standalone web service for `picoclaw`.
It provides a complete unified web interface, acting as a dashboard, configuration center, and interactive console (channel client) for the core `picoclaw` engine.
`web/` contains the standalone WebUI launcher for PicoClaw.
It is not just a frontend: it is a small launcher service that bundles a React dashboard, exposes a backend API, manages launcher authentication, and starts or attaches to the `picoclaw gateway` process.
![PicoClaw Launcher](./picoclaw-launcher.png)
## What This Directory Provides
- A browser-based chat UI backed by the Pico channel WebSocket proxy.
- A dashboard for models, credentials, channels, agent tools, skills, logs, and runtime settings.
- A launcher process that can auto-open the browser, show a system tray menu, and persist launcher-specific settings.
- A controlled way to start, stop, restart, and inspect the `picoclaw gateway` subprocess.
- A single-binary deployment target where the frontend is embedded into the Go backend.
## Architecture
The service is structured as a monorepo containing both the backend and frontend code to ensure high cohesion and simplify deployment.
This directory is a small monorepo:
* **`backend/`**: The Go-based web server. It provides RESTful APIs, manages WebSocket connections for chat, and handles the lifecycle of the `picoclaw` process. It eventually embeds the compiled frontend assets into a single executable.
* **`frontend/`**: The Vite + React + TanStack Router single-page application (SPA). It provides the interactive user interface.
- `backend/`
- Go HTTP server and launcher runtime.
- Serves REST APIs, authentication endpoints, channel helper flows, and the Pico WebSocket reverse proxy.
- Embeds compiled frontend assets from `backend/dist`.
- `frontend/`
- Vite + React 19 + TanStack Router SPA.
- Provides the launcher dashboard and chat UI.
## Getting Started
At runtime the launcher and the main PicoClaw engine are separate processes:
1. The launcher starts the web backend on port `18800` by default.
2. The launcher serves the dashboard and handles dashboard authentication.
3. When allowed, it starts or attaches to `picoclaw gateway -E`.
4. The frontend talks only to the launcher backend.
5. The launcher proxies chat traffic to the gateway through `/pico/ws`.
## Dashboard Capabilities
The current frontend exposes these major pages and flows:
- `/`
- Chat UI with session history, default model selection, and Pico channel messaging.
- `/models`
- Add, edit, delete, and set the default model.
- Supports API-key models, OAuth-backed models, and local/CLI-backed models.
- `/credentials`
- Manage provider credentials.
- Current built-in flows: OpenAI, Anthropic, and Google Antigravity.
- `/channels/*`
- Configure supported channels from a shared catalog.
- Current catalog: `weixin`, `telegram`, `discord`, `slack`, `feishu`, `dingtalk`, `line`, `qq`, `onebot`, `wecom`, `whatsapp`, `whatsapp_native`, `pico`, `maixcam`, `matrix`, `irc`.
- Includes QR-based binding helpers for WeChat and WeCom.
- `/agent/skills`
- Browse built-in, global, and workspace skills.
- Import Markdown skills into the workspace and delete workspace-owned skills.
- `/agent/tools`
- View tool availability and enable or disable tool switches through config-backed APIs.
- `/config`
- Edit agent defaults, exec controls, cron controls, heartbeat, device monitoring, launcher networking, and launch-at-login settings.
- `/logs`
- View the in-memory gateway log buffer and clear it.
The UI currently supports English and Simplified Chinese, plus light and dark themes.
## Runtime Behavior
### Config Resolution
The launcher uses the same PicoClaw config file as the main binary.
- Default app config path: `~/.picoclaw/config.json`
- Override with environment variable: `PICOCLAW_CONFIG`
- Override with a positional CLI argument: `picoclaw-launcher /path/to/config.json`
Launcher-only settings are stored beside that app config:
- File name: `launcher-config.json`
- Default location: `~/.picoclaw/launcher-config.json`
That file currently stores:
- `port`
- `public`
- `allowed_cidrs`
If `-port` or `-public` are passed explicitly, the CLI flag wins for that run.
If they are omitted, stored launcher settings are used.
### First-Run Onboarding
If the target config file does not exist, the launcher tries to bootstrap it automatically by running:
```bash
picoclaw onboard
```
The launcher looks for the main PicoClaw binary in this order:
1. `PICOCLAW_BINARY`
2. A `picoclaw` binary in the same directory as the launcher
3. `picoclaw` from `PATH`
If onboarding or gateway startup cannot find the main binary, set `PICOCLAW_BINARY` explicitly.
### Gateway Management
The launcher manages `picoclaw gateway -E`.
On startup it tries to auto-start or attach to the gateway, but only when startup preconditions pass. In the current code, the main checks are:
- a default model is configured
- the default model entry is valid
- the default model has usable credentials
- local/runtime-probed models are reachable
When a gateway process is started by the launcher, the launcher:
- captures stdout and stderr into an in-memory ring buffer
- tracks transient states such as `starting`, `restarting`, and `stopping`
- marks restart-required when the default model or enabled tool set changed since boot
- ensures the Pico channel is configured before startup
### Launcher Authentication
The dashboard is protected by a launcher access token.
- If `PICOCLAW_LAUNCHER_TOKEN` is set, that token is used.
- Otherwise a random token is generated for each launcher process.
- The browser auto-open URL includes `?token=...` so local launches can sign in automatically.
- Manual login uses `/launcher-login`.
- API clients may also authenticate with `Authorization: Bearer <token>`.
Where users can retrieve the token depends on launch mode:
- Console mode: printed to stdout
- GUI mode: available through the tray menu on supported builds
- GUI mode without stdout:
- random per-run tokens are written to the launcher log
- default log path: `~/.picoclaw/logs/launcher.log`
- if `PICOCLAW_HOME` is set, use `$PICOCLAW_HOME/logs/launcher.log`
- env-pinned tokens are not reprinted there; the log only notes that `PICOCLAW_LAUNCHER_TOKEN` is in use
### Network Exposure
By default the launcher listens on:
```text
127.0.0.1:18800
```
With `-public` or `public: true`, it listens on all interfaces:
```text
0.0.0.0:18800
```
When public access is enabled:
- the launcher can still protect the dashboard with the access token
- optional `allowed_cidrs` can restrict which client IP ranges may connect
- the gateway host is overridden so remote clients can still use the launcher-managed proxy paths
## Build And Run
### Prerequisites
* Go 1.25+
* Node.js 20+ with pnpm
- Go `1.25+`
- Node.js 20.19+ or 22.13+
- `pnpm`
### Development
On macOS, the `web` Makefile enables `CGO_ENABLED=1` so tray-enabled launcher builds work as expected.
On Darwin or FreeBSD without cgo, the launcher falls back to headless mode without a tray.
Run both the frontend dev server and the Go backend simultaneously:
If you want to prepare the frontend workspace manually, you can still install dependencies yourself:
```bash
cd frontend
pnpm install
```
### Recommended Development Workflow
From the `web/` directory:
```bash
make dev
```
Or run them separately:
This does three things:
1. Builds `../build/picoclaw` for launcher development.
2. Starts the Go backend with `PICOCLAW_BINARY` pointing at that binary.
3. Starts the Vite frontend dev server.
Use this when you want the full launcher flow during development.
### Run Frontend And Backend Separately
```bash
make dev-frontend # Vite dev server
make dev-backend # Go backend
make dev-frontend
make dev-backend
```
### Build
Notes:
Build the frontend and embed it into a single Go binary:
- `dev-frontend` runs the Vite server.
- `dev-backend` runs the Go backend only.
- The Vite dev server proxies `/api` to `http://localhost:18800`.
- Chat WebSocket URLs are generated by the backend, so the frontend does not hardcode gateway addresses.
- Running `dev-backend` alone is mainly useful for backend work or when `backend/dist` already contains a built frontend.
### Build The Standalone Launcher Binary
From `web/`:
```bash
make build
```
The output binary is `backend/picoclaw-web`.
This:
### Other Commands
1. Installs frontend dependencies when needed.
2. Builds the frontend into `backend/dist`.
3. Embeds those assets into the Go backend.
4. Produces `build/picoclaw-launcher`.
Override the output path if needed:
```bash
make test # Run backend tests and frontend lint
make lint # Run go vet and prettier/eslint
make clean # Remove all build artifacts
make build OUTPUT=/tmp/picoclaw-launcher
```
From the repository root you can also use:
```bash
make build-launcher
```
That writes the platform-specific launcher to:
```text
build/picoclaw-launcher-<platform>-<arch>
```
and refreshes the `build/picoclaw-launcher` symlink.
### Frontend-Only Builds
For frontend work there are two useful package scripts:
```bash
cd frontend
pnpm build
pnpm build:backend
```
- `pnpm build` writes a normal Vite build to `frontend/dist`
- `pnpm build:backend` writes the embeddable build to `../backend/dist`
### Run The Built Launcher
Examples:
```bash
./build/picoclaw-launcher
./build/picoclaw-launcher -console
./build/picoclaw-launcher -public
./build/picoclaw-launcher -port 19999 /path/to/config.json
```
Current launcher flags:
- `-port`
- `-public`
- `-no-browser`
- `-lang`
- `-console`
## Make Targets
From `web/`:
```bash
make dev
make dev-frontend
make dev-backend
make build
make build-frontend
make test
make lint
make clean
```
What they do today:
- `make build-frontend`
- Runs `pnpm install --frozen-lockfile` when dependencies are missing or stale.
- Builds the embeddable frontend into `backend/dist`.
- `make test`
- Runs backend Go tests.
- Runs frontend `pnpm lint`.
- `make lint`
- Runs backend `go vet`.
- Runs frontend `pnpm check`.
- `pnpm check` currently formats files with Prettier and fixes lint issues with ESLint, so this target can modify your working tree.
- `make clean`
- Removes `frontend/dist`, `backend/dist`, and `build/`, then recreates `backend/dist/.gitkeep`.
## Directory Layout
```text
web/
├── backend/
│ ├── api/ # REST API handlers and launcher runtime endpoints
│ ├── launcherconfig/ # launcher-config.json load/save/validation
│ ├── middleware/ # auth, content type, logging, CIDR allowlist
│ ├── model/ # Go data structures and logic wrappers
│ ├── utils/ # runtime helpers, onboarding, browser launch
│ ├── winres/ # Windows application resources
│ └── dist/ # embedded frontend build output
├── frontend/
│ ├── src/api/ # browser API clients
│ ├── src/components/ # UI pages and shared components
│ ├── src/features/ # feature-specific state, controllers, and protocol helpers
│ ├── src/hooks/ # shared React hooks
│ ├── src/i18n/ # internationalization language packs
│ ├── src/lib/ # generic library utilities
│ ├── src/routes/ # TanStack file routes
│ ├── src/store/ # global state management
│ └── vite.config.ts # dev server and build config
├── Makefile
└── README.md
```
## Troubleshooting
### You have to sign in again after the launcher restarts
Existing dashboard sessions do not survive launcher restarts.
That is expected: each launcher process generates a new signed session value, so old cookies become invalid.
To make re-login easier, set a stable token:
```bash
export PICOCLAW_LAUNCHER_TOKEN="replace-with-a-long-random-token"
```
Notes:
- a stable token does not preserve the old cookie-based session by itself
- when the launcher opens the browser automatically, it appends `?token=...` and signs in again automatically
- if you reopen the dashboard manually, use the same stable token on `/launcher-login`
### "Start Gateway" stays disabled
The launcher only allows gateway startup when the configured default model is usable.
Check these in the dashboard:
- a default model is selected
- the model has credentials or OAuth state
- local models such as Ollama or vLLM are reachable
### The launcher cannot find `picoclaw`
Set the main binary explicitly:
```bash
export PICOCLAW_BINARY=/absolute/path/to/picoclaw
```
This affects onboarding and gateway subprocess startup.
### The backend starts but the UI is blank in development
Use `make dev` for the normal workflow.
If you run only `make dev-backend`, either run `make dev-frontend` alongside it or build the embedded frontend first with `make build-frontend`.
## Related Docs
- Main project overview: [`../README.md`](../README.md)
- Configuration guide: [`../docs/configuration.md`](../docs/configuration.md)
- Providers: [`../docs/providers.md`](../docs/providers.md)
- Troubleshooting: [`../docs/troubleshooting.md`](../docs/troubleshooting.md)
- Official docs site: [docs.picoclaw.io](https://docs.picoclaw.io)

View file

@ -52,6 +52,11 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
var raw map[string]any
if err = json.Unmarshal(body, &raw); err != nil {
http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest)
return
}
if execAllowRemoteOmitted(body) {
cfg.Tools.Exec.AllowRemote = config.DefaultConfig().Tools.Exec.AllowRemote
}
@ -63,6 +68,7 @@ func (h *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
applyConfigSecretsFromMap(&cfg, raw)
if errs := validateConfig(&cfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
@ -162,6 +168,7 @@ func (h *Handler) handlePatchConfig(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("Failed to apply security config: %v", err), http.StatusInternalServerError)
return
}
applyConfigSecretsFromMap(&newCfg, base)
if errs := validateConfig(&newCfg); len(errs) > 0 {
w.Header().Set("Content-Type", "application/json")
@ -331,3 +338,146 @@ func mergeMap(dst, src map[string]any) {
}
}
}
func asMapField(value map[string]any, key string) (map[string]any, bool) {
raw, exists := value[key]
if !exists {
return nil, false
}
m, isMap := raw.(map[string]any)
return m, isMap
}
func getSecretString(m map[string]any, key string) (string, bool) {
if raw, exists := m[key]; exists {
s, isString := raw.(string)
if isString {
return s, true
}
}
if raw, exists := m["_"+key]; exists {
s, isString := raw.(string)
if isString {
return s, true
}
}
return "", false
}
func applyConfigSecretsFromMap(cfg *config.Config, raw map[string]any) {
channels, hasChannels := asMapField(raw, "channels")
if hasChannels {
if telegram, hasTelegram := asMapField(channels, "telegram"); hasTelegram {
if token, hasToken := getSecretString(telegram, "token"); hasToken {
cfg.Channels.Telegram.SetToken(token)
}
}
if feishu, hasFeishu := asMapField(channels, "feishu"); hasFeishu {
if appSecret, hasAppSecret := getSecretString(feishu, "app_secret"); hasAppSecret {
cfg.Channels.Feishu.AppSecret.Set(appSecret)
}
if encryptKey, hasEncryptKey := getSecretString(feishu, "encrypt_key"); hasEncryptKey {
cfg.Channels.Feishu.EncryptKey.Set(encryptKey)
}
if verificationToken, hasVerificationToken := getSecretString(
feishu,
"verification_token",
); hasVerificationToken {
cfg.Channels.Feishu.VerificationToken.Set(verificationToken)
}
}
if discord, hasDiscord := asMapField(channels, "discord"); hasDiscord {
if token, hasToken := getSecretString(discord, "token"); hasToken {
cfg.Channels.Discord.Token.Set(token)
}
}
if weixin, hasWeixin := asMapField(channels, "weixin"); hasWeixin {
if token, hasToken := getSecretString(weixin, "token"); hasToken {
cfg.Channels.Weixin.SetToken(token)
}
}
if qq, hasQQ := asMapField(channels, "qq"); hasQQ {
if appSecret, hasAppSecret := getSecretString(qq, "app_secret"); hasAppSecret {
cfg.Channels.QQ.AppSecret.Set(appSecret)
}
}
if dingtalk, hasDingTalk := asMapField(channels, "dingtalk"); hasDingTalk {
if clientSecret, hasClientSecret := getSecretString(dingtalk, "client_secret"); hasClientSecret {
cfg.Channels.DingTalk.ClientSecret.Set(clientSecret)
}
}
if slack, hasSlack := asMapField(channels, "slack"); hasSlack {
if botToken, hasBotToken := getSecretString(slack, "bot_token"); hasBotToken {
cfg.Channels.Slack.BotToken.Set(botToken)
}
if appToken, hasAppToken := getSecretString(slack, "app_token"); hasAppToken {
cfg.Channels.Slack.AppToken.Set(appToken)
}
}
if matrix, hasMatrix := asMapField(channels, "matrix"); hasMatrix {
if accessToken, hasAccessToken := getSecretString(matrix, "access_token"); hasAccessToken {
cfg.Channels.Matrix.AccessToken.Set(accessToken)
}
}
if line, hasLine := asMapField(channels, "line"); hasLine {
if channelSecret, hasChannelSecret := getSecretString(line, "channel_secret"); hasChannelSecret {
cfg.Channels.LINE.ChannelSecret.Set(channelSecret)
}
if channelAccessToken, hasChannelAccessToken := getSecretString(
line,
"channel_access_token",
); hasChannelAccessToken {
cfg.Channels.LINE.ChannelAccessToken.Set(channelAccessToken)
}
}
if onebot, hasOneBot := asMapField(channels, "onebot"); hasOneBot {
if accessToken, hasAccessToken := getSecretString(onebot, "access_token"); hasAccessToken {
cfg.Channels.OneBot.AccessToken.Set(accessToken)
}
}
if wecom, hasWeCom := asMapField(channels, "wecom"); hasWeCom {
if secret, hasSecret := getSecretString(wecom, "secret"); hasSecret {
cfg.Channels.WeCom.SetSecret(secret)
}
}
if pico, hasPico := asMapField(channels, "pico"); hasPico {
if token, hasToken := getSecretString(pico, "token"); hasToken {
cfg.Channels.Pico.SetToken(token)
}
}
if irc, hasIRC := asMapField(channels, "irc"); hasIRC {
if password, hasPassword := getSecretString(irc, "password"); hasPassword {
cfg.Channels.IRC.Password.Set(password)
}
if nickservPassword, hasNickservPassword := getSecretString(irc, "nickserv_password"); hasNickservPassword {
cfg.Channels.IRC.NickServPassword.Set(nickservPassword)
}
if saslPassword, hasSASLPassword := getSecretString(irc, "sasl_password"); hasSASLPassword {
cfg.Channels.IRC.SASLPassword.Set(saslPassword)
}
}
}
tools, hasTools := asMapField(raw, "tools")
if !hasTools {
return
}
skills, hasSkills := asMapField(tools, "skills")
if !hasSkills {
return
}
if github, hasGithub := asMapField(skills, "github"); hasGithub {
if token, hasToken := getSecretString(github, "token"); hasToken {
cfg.Tools.Skills.Github.Token.Set(token)
}
}
registries, hasRegistries := asMapField(skills, "registries")
if !hasRegistries {
return
}
if clawHub, hasClawHub := asMapField(registries, "clawhub"); hasClawHub {
if authToken, hasAuthToken := getSecretString(clawHub, "auth_token"); hasAuthToken {
cfg.Tools.Skills.Registries.ClawHub.AuthToken.Set(authToken)
}
}
}

View file

@ -251,6 +251,42 @@ func TestHandlePatchConfig_SucceedsWhenPicoTokenInSecurityOnly(t *testing.T) {
}
}
func TestHandlePatchConfig_SavesDiscordTokenFromPayload(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodPatch, "/api/config", bytes.NewBufferString(`{
"channels": {
"discord": {
"enabled": true,
"token": "discord-test-token"
}
}
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("PATCH /api/config status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
cfg, err := config.LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error = %v", err)
}
if !cfg.Channels.Discord.Enabled {
t.Fatal("discord should be enabled after PATCH")
}
if got := cfg.Channels.Discord.Token.String(); got != "discord-test-token" {
t.Fatalf("discord token = %q, want %q", got, "discord-test-token")
}
}
func TestHandlePatchConfig_AllowsInvalidDenyRegexPatternsWhenDenyPatternsDisabled(t *testing.T) {
configPath, cleanup := setupOAuthTestEnv(t)
defer cleanup()

View file

@ -40,6 +40,7 @@ type modelResponse struct {
ThinkingLevel string `json:"thinking_level,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
// Meta
Enabled bool `json:"enabled"`
Configured bool `json:"configured"`
IsDefault bool `json:"is_default"`
IsVirtual bool `json:"is_virtual"`
@ -85,6 +86,7 @@ func (h *Handler) handleListModels(w http.ResponseWriter, r *http.Request) {
RequestTimeout: m.RequestTimeout,
ThinkingLevel: m.ThinkingLevel,
ExtraBody: m.ExtraBody,
Enabled: m.Enabled,
Configured: configured[i],
IsDefault: m.ModelName == defaultModel,
IsVirtual: m.IsVirtual(),

View file

@ -76,6 +76,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
// Launcher service parameters (port/public)
h.registerLauncherConfigRoutes(mux)
// Runtime build/version metadata
h.registerVersionRoutes(mux)
// WeChat QR login flow
h.registerWeixinRoutes(mux)

345
web/backend/api/version.go Normal file
View file

@ -0,0 +1,345 @@
package api
import (
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"regexp"
"runtime"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/web/backend/utils"
)
type systemVersionResponse struct {
Version string `json:"version"`
GitCommit string `json:"git_commit,omitempty"`
BuildTime string `json:"build_time,omitempty"`
GoVersion string `json:"go_version"`
}
type cachedSystemVersion struct {
value systemVersionResponse
gatewayPID int
}
type systemVersionCache struct {
mu sync.Mutex
current cachedSystemVersion
hasCurrent bool
inflightCh chan struct{}
}
func newSystemVersionCache() *systemVersionCache {
return &systemVersionCache{}
}
var (
// 15 seconds matches the gateway startup window used elsewhere in launcher flow,
// giving slow/embedded hosts enough time for first command invocation while
// staying independent from cross-file init ordering.
versionCmdTimeout = 15 * time.Second
maxVersionResolveAttempts = 3
findPicoclawBinaryForInfo = resolveGatewayBinaryForVersionInfo
runPicoclawVersionOutput = executePicoclawVersion
currentGatewayVersionState = gatewayVersionState
launcherBuildInfoForVersion = fallbackSystemVersionInfoFromConfig
versionInfoCache = newSystemVersionCache()
ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
versionLinePattern = regexp.MustCompile(
`^(?:[^A-Za-z0-9]*\s*)?picoclaw(?:\.exe)?\s+([^\s(]+)` +
`(?:\s+\(git:\s*([^)]+)\))?\s*$`,
)
)
func (h *Handler) registerVersionRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/system/version", h.handleGetVersion)
}
// handleGetVersion returns runtime version information for web clients.
func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
versionInfo := h.resolveSystemVersionInfo(r.Context())
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(versionInfo); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
// resolveSystemVersionInfo prefers the actual picoclaw binary version output,
// and falls back to launcher build metadata when command execution fails.
func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse {
for range maxVersionResolveAttempts {
gatewayPID, gatewayAlive := currentGatewayVersionState()
if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok {
return cached
}
leader, ok := versionInfoCache.waitOrStart(ctx)
if !ok {
return fallbackSystemVersionInfo()
}
if !leader {
continue
}
resolved := h.resolveSystemVersionInfoUncached(ctx)
gatewayPID, gatewayAlive = currentGatewayVersionState()
versionInfoCache.finishResolve(resolved, gatewayPID, gatewayAlive)
return resolved
}
return fallbackSystemVersionInfo()
}
func (h *Handler) resolveSystemVersionInfoUncached(ctx context.Context) systemVersionResponse {
if ctx == nil {
ctx = context.Background()
}
fallback := fallbackSystemVersionInfo()
execPath := strings.TrimSpace(findPicoclawBinaryForInfo())
if execPath == "" {
return fallback
}
cmdCtx, cancel := context.WithTimeout(ctx, versionCmdTimeout)
defer cancel()
output, err := runPicoclawVersionOutput(cmdCtx, execPath)
if err != nil {
return fallback
}
parsed, ok := parsePicoclawVersionOutput(output)
if !ok {
return fallback
}
if parsed.GoVersion == "" {
parsed.GoVersion = fallback.GoVersion
if parsed.GoVersion == "" {
parsed.GoVersion = runtime.Version()
}
}
return parsed
}
func fallbackSystemVersionInfo() systemVersionResponse {
return launcherBuildInfoForVersion()
}
func fallbackSystemVersionInfoFromConfig() systemVersionResponse {
buildTime, goVer := config.FormatBuildInfo()
return systemVersionResponse{
Version: config.GetVersion(),
GitCommit: config.GitCommit,
BuildTime: buildTime,
GoVersion: goVer,
}
}
// resolveGatewayBinaryForVersionInfo uses the same executable as the launcher
// gateway start path when available, then falls back to launcher binary lookup.
// This keeps version probing aligned with the actual gateway startup behavior,
// so web and gateway do not drift onto different binaries.
func resolveGatewayBinaryForVersionInfo() string {
gateway.mu.Lock()
cmd := gateway.cmd
gateway.mu.Unlock()
if cmd != nil {
if execPath := strings.TrimSpace(cmd.Path); execPath != "" {
return execPath
}
}
return utils.FindPicoclawBinary()
}
func gatewayVersionState() (int, bool) {
gateway.mu.Lock()
defer gateway.mu.Unlock()
if gateway.cmd == nil || gateway.cmd.Process == nil {
return 0, false
}
pid := gateway.cmd.Process.Pid
if pid <= 0 {
return 0, false
}
return pid, isCmdProcessAliveLocked(gateway.cmd)
}
func (c *systemVersionCache) get(gatewayPID int, gatewayAlive bool) (systemVersionResponse, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.hasCurrent && (!gatewayAlive || gatewayPID <= 0 || gatewayPID != c.current.gatewayPID) {
c.clearCurrentLocked()
}
if c.hasCurrent {
return c.current.value, true
}
return systemVersionResponse{}, false
}
func (c *systemVersionCache) waitOrStart(ctx context.Context) (bool, bool) {
if ctx == nil {
ctx = context.Background()
}
if ctx.Err() != nil {
return false, false
}
c.mu.Lock()
if c.inflightCh == nil {
c.inflightCh = make(chan struct{})
c.mu.Unlock()
return true, true
}
waitCh := c.inflightCh
c.mu.Unlock()
select {
case <-waitCh:
return false, true
case <-ctx.Done():
return false, false
}
}
func (c *systemVersionCache) finishResolve(value systemVersionResponse, gatewayPID int, gatewayAlive bool) {
c.mu.Lock()
if gatewayAlive && gatewayPID > 0 {
c.current = cachedSystemVersion{value: value, gatewayPID: gatewayPID}
c.hasCurrent = true
} else {
c.clearCurrentLocked()
}
inflightCh := c.inflightCh
c.inflightCh = nil
c.mu.Unlock()
if inflightCh != nil {
close(inflightCh)
}
}
func (c *systemVersionCache) clearCurrentLocked() {
c.hasCurrent = false
c.current = cachedSystemVersion{}
}
func (c *systemVersionCache) resetForTest() {
c.mu.Lock()
defer c.mu.Unlock()
c.current = cachedSystemVersion{}
c.hasCurrent = false
if c.inflightCh != nil {
close(c.inflightCh)
c.inflightCh = nil
}
}
// executePicoclawVersion runs the version subcommand against the
// discovered picoclaw executable.
func executePicoclawVersion(ctx context.Context, execPath string) (string, error) {
out, err := exec.CommandContext(ctx, execPath, "version").CombinedOutput()
if err == nil {
return string(out), nil
}
return string(out), fmt.Errorf("failed to execute version command: %w", err)
}
// parsePicoclawVersionOutput extracts version/build/go fields from CLI output.
// It accepts banner/ANSI-decorated output and only requires the version line.
func parsePicoclawVersionOutput(raw string) (systemVersionResponse, bool) {
var result systemVersionResponse
scanner := bufio.NewScanner(strings.NewReader(raw))
for scanner.Scan() {
line := strings.TrimSpace(ansiEscapePattern.ReplaceAllString(scanner.Text(), ""))
if line == "" {
continue
}
if match := versionLinePattern.FindStringSubmatch(line); len(match) > 0 {
candidateVersion := strings.TrimSpace(match[1])
if !isLikelyVersionValue(candidateVersion) {
continue
}
result.Version = candidateVersion
if len(match) > 2 {
result.GitCommit = strings.TrimSpace(match[2])
}
continue
}
if buildValue, ok := strings.CutPrefix(line, "Build:"); ok {
result.BuildTime = strings.TrimSpace(buildValue)
continue
}
if goValue, ok := strings.CutPrefix(line, "Go:"); ok {
result.GoVersion = strings.TrimSpace(goValue)
}
}
if err := scanner.Err(); err != nil {
return systemVersionResponse{}, false
}
if result.Version == "" {
return systemVersionResponse{}, false
}
return result, true
}
func isLikelyVersionValue(value string) bool {
v := strings.TrimSpace(strings.ToLower(value))
if v == "" {
return false
}
if v == "dev" {
return true
}
// Accept git-like short/long hashes even when they contain only letters (a-f).
if len(v) >= 7 && len(v) <= 40 {
allHex := true
for _, ch := range v {
if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') {
continue
}
allHex = false
break
}
if allHex {
return true
}
}
for _, ch := range v {
if ch >= '0' && ch <= '9' {
return true
}
}
return false
}

View file

@ -0,0 +1,317 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os/exec"
"runtime"
"testing"
)
func setupVersionTestIsolation(t *testing.T) {
t.Helper()
originalGatewayState := currentGatewayVersionState
originalFinder := findPicoclawBinaryForInfo
originalRunner := runPicoclawVersionOutput
originalFallback := launcherBuildInfoForVersion
t.Cleanup(func() {
currentGatewayVersionState = originalGatewayState
findPicoclawBinaryForInfo = originalFinder
runPicoclawVersionOutput = originalRunner
launcherBuildInfoForVersion = originalFallback
versionInfoCache.resetForTest()
})
currentGatewayVersionState = func() (int, bool) { return 0, false }
versionInfoCache.resetForTest()
}
func TestGetSystemVersionUsesPicoclawBinaryInfo(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "fallback", GoVersion: "go-fallback"}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
return "🦞 picoclaw v1.2.3 (git: deadbeef)\n Build: 2026-03-27T12:34:56Z\n Go: go1.25.8\n", nil
}
h := NewHandler("")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var got systemVersionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got.Version != "v1.2.3" {
t.Fatalf("version = %q, want %q", got.Version, "v1.2.3")
}
if got.GitCommit != "deadbeef" {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "deadbeef")
}
if got.BuildTime != "2026-03-27T12:34:56Z" {
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T12:34:56Z")
}
if got.GoVersion != "go1.25.8" {
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
}
}
func TestGetSystemVersionFallsBackToLauncherInfoWhenCommandFails(t *testing.T) {
setupVersionTestIsolation(t)
expected := systemVersionResponse{
Version: "v9.9.9",
GitCommit: "cafebabe",
BuildTime: "2026-03-27T10:43:34+0000",
GoVersion: "go1.25.8",
}
launcherBuildInfoForVersion = func() systemVersionResponse { return expected }
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
return "", errors.New("binary unavailable")
}
h := NewHandler("")
mux := http.NewServeMux()
h.RegisterRoutes(mux)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/system/version", nil)
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var got systemVersionResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if got.Version != expected.Version {
t.Fatalf("version = %q, want %q", got.Version, expected.Version)
}
if got.GitCommit != expected.GitCommit {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, expected.GitCommit)
}
if got.BuildTime != expected.BuildTime {
t.Fatalf("build_time = %q, want %q", got.BuildTime, expected.BuildTime)
}
if got.GoVersion != expected.GoVersion {
t.Fatalf("go_version = %q, want %q", got.GoVersion, expected.GoVersion)
}
}
func TestParsePicoclawVersionOutput(t *testing.T) {
setupVersionTestIsolation(t)
raw := "\u001b[1;31m████\u001b[0m\n🦞 picoclaw 18ec263 (git: 18ec2631)\n Build: 2026-03-27T10:43:34+0000\n Go: go1.25.8\n"
got, ok := parsePicoclawVersionOutput(raw)
if !ok {
t.Fatal("parsePicoclawVersionOutput() should parse valid output")
}
if got.Version != "18ec263" {
t.Fatalf("version = %q, want %q", got.Version, "18ec263")
}
if got.GitCommit != "18ec2631" {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "18ec2631")
}
if got.BuildTime != "2026-03-27T10:43:34+0000" {
t.Fatalf("build_time = %q, want %q", got.BuildTime, "2026-03-27T10:43:34+0000")
}
if got.GoVersion != "go1.25.8" {
t.Fatalf("go_version = %q, want %q", got.GoVersion, "go1.25.8")
}
}
func TestParsePicoclawVersionOutputIgnoresUsageLine(t *testing.T) {
setupVersionTestIsolation(t)
raw := "Usage: picoclaw version [flags]\n"
got, ok := parsePicoclawVersionOutput(raw)
if ok {
t.Fatalf("parsePicoclawVersionOutput() parsed usage line unexpectedly: %#v", got)
}
}
func TestParsePicoclawVersionOutputAcceptsLetterOnlyHashVersion(t *testing.T) {
setupVersionTestIsolation(t)
raw := "picoclaw abcdefa (git: abcdefabcdefabcdefabcdefabcdefabcdefabcd)\n"
got, ok := parsePicoclawVersionOutput(raw)
if !ok {
t.Fatal("parsePicoclawVersionOutput() should parse letter-only hash version")
}
if got.Version != "abcdefa" {
t.Fatalf("version = %q, want %q", got.Version, "abcdefa")
}
if got.GitCommit != "abcdefabcdefabcdefabcdefabcdefabcdefabcd" {
t.Fatalf("git_commit = %q, want %q", got.GitCommit, "abcdefabcdefabcdefabcdefabcdefabcdefabcd")
}
}
func TestResolveSystemVersionInfoFallsBackRuntimeGoVersion(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "dev", GoVersion: ""}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
return "picoclaw v1.0.0\n", nil
}
h := NewHandler("")
got := h.resolveSystemVersionInfo(context.Background())
if got.GoVersion != runtime.Version() {
t.Fatalf("go_version = %q, want runtime version %q", got.GoVersion, runtime.Version())
}
}
func TestResolveSystemVersionInfoCachesWhileGatewayAlive(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
pid := 4321
currentGatewayVersionState = func() (int, bool) { return pid, true }
runCount := 0
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
runCount++
return fmt.Sprintf("picoclaw v1.2.%d\n", runCount), nil
}
h := NewHandler("")
first := h.resolveSystemVersionInfo(context.Background())
second := h.resolveSystemVersionInfo(context.Background())
if first.Version != "v1.2.1" {
t.Fatalf("first version = %q, want %q", first.Version, "v1.2.1")
}
if second.Version != "v1.2.1" {
t.Fatalf("second version = %q, want cached %q", second.Version, "v1.2.1")
}
if runCount != 1 {
t.Fatalf("run count = %d, want %d", runCount, 1)
}
}
func TestResolveSystemVersionInfoInvalidatesCacheWhenGatewayStops(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "dev", GoVersion: "go-fallback"}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
alive := true
pid := 9876
currentGatewayVersionState = func() (int, bool) {
if !alive {
return 0, false
}
return pid, true
}
runCount := 0
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
runCount++
return fmt.Sprintf("picoclaw v2.0.%d\n", runCount), nil
}
h := NewHandler("")
first := h.resolveSystemVersionInfo(context.Background())
second := h.resolveSystemVersionInfo(context.Background())
if first.Version != "v2.0.1" || second.Version != "v2.0.1" {
t.Fatalf("expected cached version v2.0.1, got first=%q second=%q", first.Version, second.Version)
}
if runCount != 1 {
t.Fatalf("run count after cache hit = %d, want %d", runCount, 1)
}
alive = false
third := h.resolveSystemVersionInfo(context.Background())
if third.Version != "v2.0.2" {
t.Fatalf("third version = %q, want refreshed %q", third.Version, "v2.0.2")
}
if runCount != 2 {
t.Fatalf("run count after invalidation = %d, want %d", runCount, 2)
}
}
func TestResolveSystemVersionInfoSkipsCommandWhenContextCanceled(t *testing.T) {
setupVersionTestIsolation(t)
launcherBuildInfoForVersion = func() systemVersionResponse {
return systemVersionResponse{Version: "v3.0.0", GoVersion: "go-fallback"}
}
findPicoclawBinaryForInfo = func() string { return "picoclaw" }
runCount := 0
runPicoclawVersionOutput = func(_ context.Context, _ string) (string, error) {
runCount++
return "picoclaw v9.9.9\n", nil
}
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
h := NewHandler("")
got := h.resolveSystemVersionInfo(canceledCtx)
if runCount != 0 {
t.Fatalf("run count = %d, want %d", runCount, 0)
}
if got.Version != "v3.0.0" {
t.Fatalf("version = %q, want fallback %q", got.Version, "v3.0.0")
}
}
func TestResolveGatewayBinaryForVersionInfoPrefersGatewayCommandPath(t *testing.T) {
setupVersionTestIsolation(t)
originalFinder := findPicoclawBinaryForInfo
t.Cleanup(func() {
findPicoclawBinaryForInfo = originalFinder
})
gateway.mu.Lock()
originalCmd := gateway.cmd
gateway.cmd = &exec.Cmd{Path: "/tmp/picoclaw-from-gateway"}
gateway.mu.Unlock()
t.Cleanup(func() {
gateway.mu.Lock()
gateway.cmd = originalCmd
gateway.mu.Unlock()
})
got := resolveGatewayBinaryForVersionInfo()
if got != "/tmp/picoclaw-from-gateway" {
t.Fatalf("exec path = %q, want %q", got, "/tmp/picoclaw-from-gateway")
}
}

View file

@ -3,6 +3,9 @@
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
@ -22,7 +25,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.20",
"i18next": "^25.8.14",
"i18next": "^26.0.1",
"i18next-browser-languagedetector": "^8.2.1",
"jotai": "^2.18.1",
"radix-ui": "^1.4.3",
@ -42,7 +45,7 @@
"wrap-ansi": "^10.0.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/router-plugin": "^1.164.0",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
@ -50,16 +53,16 @@
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.57.1",
"@vitejs/plugin-react": "^5.2.0",
"eslint": "^9.39.4",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.1.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^16.5.0",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "^7.3.1"
"vite": "^8.0.3"
}
}

File diff suppressed because it is too large Load diff

View file

@ -13,6 +13,13 @@ export interface LauncherConfig {
allowed_cidrs: string[]
}
export interface SystemVersionInfo {
version: string
git_commit?: string
build_time?: string
go_version: string
}
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await launcherFetch(path, options)
if (!res.ok) {
@ -62,3 +69,7 @@ export async function setLauncherConfig(
body: JSON.stringify(payload),
})
}
export async function getSystemVersionInfo(): Promise<SystemVersionInfo> {
return request<SystemVersionInfo>("/api/system/version")
}

View file

@ -10,10 +10,12 @@ import {
IconSparkles,
IconTools,
} from "@tabler/icons-react"
import { useQuery } from "@tanstack/react-query"
import { Link, useRouterState } from "@tanstack/react-router"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { getSystemVersionInfo } from "@/api/system"
import {
Collapsible,
CollapsibleContent,
@ -27,6 +29,7 @@ import {
SidebarGroupLabel,
SidebarMenu,
SidebarMenuButton,
SidebarFooter,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar"
@ -78,6 +81,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(),
t,
})
const { data: versionInfo } = useQuery({
queryKey: ["system", "version"],
queryFn: getSystemVersionInfo,
staleTime: 5 * 60 * 1000,
})
const versionText = versionInfo?.version ?? t("footer.version_unknown")
const navGroups: NavGroup[] = React.useMemo(() => {
return [
@ -235,6 +245,26 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</Collapsible>
))}
</SidebarContent>
<SidebarFooter className="border-t-border/30 group-data-[collapsible=icon]:hidden border-t px-3 py-2">
<div className="text-muted-foreground flex flex-col gap-0.5 text-[11px] leading-4">
<div className="truncate" title={versionText}>
<span className="text-foreground/80">{t("footer.version")}:</span>{" "}
{versionText}
</div>
{versionInfo?.git_commit && (
<div className="truncate" title={versionInfo.git_commit}>
<span className="text-foreground/80">{t("footer.commit")}:</span>{" "}
{versionInfo.git_commit}
</div>
)}
{versionInfo?.build_time && (
<div className="truncate" title={versionInfo.build_time}>
<span className="text-foreground/80">{t("footer.build")}:</span>{" "}
{versionInfo.build_time}
</div>
)}
</div>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)

View file

@ -62,10 +62,8 @@ function asBool(value: unknown): boolean {
function buildEditConfig(config: ChannelConfig): ChannelConfig {
const edit: ChannelConfig = { ...config }
for (const secretKey of Object.keys(SECRET_FIELD_MAP)) {
if (secretKey in config) {
edit[SECRET_FIELD_MAP[secretKey]] = ""
}
for (const editKey of Object.values(SECRET_FIELD_MAP)) {
edit[editKey] = ""
}
return edit
}
@ -94,17 +92,22 @@ function buildSavePayload(
for (const [key, value] of Object.entries(editConfig)) {
if (key.startsWith("_")) continue
if (key === "enabled") continue
if (key in SECRET_FIELD_MAP) {
const editKey = SECRET_FIELD_MAP[key]
const incoming = asString(editConfig[editKey])
payload[key] = incoming !== "" ? incoming : value
continue
}
if (key in SECRET_FIELD_MAP) continue
payload[key] = value
}
for (const [secretKey, editKey] of Object.entries(SECRET_FIELD_MAP)) {
const incoming = asString(editConfig[editKey])
if (incoming !== "") {
payload[secretKey] = incoming
continue
}
if (secretKey in editConfig) {
payload[secretKey] = editConfig[secretKey]
}
}
if (channel.name === "whatsapp_native") {
payload.use_native = true
}

View file

@ -169,7 +169,7 @@ export function SkillsPage() {
{data.skills.map((skill) => (
<Card
key={`${skill.source}:${skill.name}`}
className="border-border/60 gap-4 bg-white/80"
className="border-border/60 gap-4"
size="sm"
>
<CardHeader>
@ -211,7 +211,7 @@ export function SkillsPage() {
<div className="text-muted-foreground text-[11px] tracking-[0.18em] uppercase">
{t("pages.agent.skills.path")}
</div>
<div className="bg-muted/60 overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed">
<div className="bg-muted text-foreground overflow-x-auto rounded-lg px-3 py-2 font-mono text-xs leading-relaxed">
{skill.path}
</div>
</CardContent>

View file

@ -93,6 +93,12 @@
"labels": {
"loading": "Loading..."
},
"footer": {
"version": "Version",
"commit": "Commit",
"build": "Build",
"version_unknown": "Unknown"
},
"credentials": {
"description": "Manage OAuth and token-based credentials for supported providers.",
"loading": "Loading credentials...",

View file

@ -93,6 +93,12 @@
"labels": {
"loading": "加载中..."
},
"footer": {
"version": "版本",
"commit": "提交",
"build": "构建",
"version_unknown": "未知"
},
"credentials": {
"description": "管理已支持服务商的 OAuth 与 Token 凭据。",
"loading": "正在加载凭据...",