Merge branch 'refactor/channel-system' into main

This commit is contained in:
美電球 2026-02-27 20:04:08 +08:00 committed by GitHub
commit fa68023ac2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 748 additions and 284 deletions

View file

@ -39,7 +39,9 @@ builds:
dockers_v2: dockers_v2:
- id: picoclaw - id: picoclaw
dockerfile: Dockerfile.goreleaser dockerfile: docker/Dockerfile.goreleaser
extra_files:
- docker/entrypoint.sh
ids: ids:
- picoclaw - picoclaw
images: images:

View file

@ -164,39 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. Configurez vos clés API # 2. Premier lancement — génère docker/data/config.json puis s'arrête
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc. # Le conteneur affiche "First-run setup complete." puis s'arrête.
# 3. Compiler & Démarrer # 3. Configurez vos clés API
docker compose --profile gateway up -d vim docker/data/config.json # Clés API du fournisseur, tokens de bot, etc.
# 4. Démarrer
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`. > **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
```bash
# 5. Voir les logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. Voir les logs # 6. Arrêter
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. Arrêter
docker compose --profile gateway down
``` ```
### Mode Agent (exécution unique) ### Mode Agent (exécution unique)
```bash ```bash
# Poser une question # Poser une question
docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Combien font 2+2 ?"
# Mode interactif # Mode interactif
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### Recompiler ### Mettre à jour
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 Démarrage Rapide ### 🚀 Démarrage Rapide

View file

@ -126,39 +126,43 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. API キーを設定 # 2. 初回起動 — docker/data/config.json を自動生成して終了
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定 # コンテナが "First-run setup complete." を表示して停止します。
# 3. ビルドと起動 # 3. API キーを設定
docker compose --profile gateway up -d vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
# 4. 起動
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。 > **Docker ユーザー**: デフォルトでは、Gateway は `127.0.0.1` でリッスンしており、ホストからアクセスできません。ヘルスチェックエンドポイントにアクセスしたり、ポートを公開したりする必要がある場合は、環境変数で `PICOCLAW_GATEWAY_HOST=0.0.0.0` を設定するか、`config.json` を更新してください。
```bash
# 5. ログ確認
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. ログ確認 # 6. 停止
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. 停止
docker compose --profile gateway down
``` ```
### Agent モード(ワンショット) ### Agent モード(ワンショット)
```bash ```bash
# 質問を投げる # 質問を投げる
docker compose run --rm picoclaw-agent -m "What is 2+2?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
# インタラクティブモード # インタラクティブモード
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### リビルド ### アップデート
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 クイックスタート(ネイティブ) ### 🚀 クイックスタート(ネイティブ)

View file

@ -172,39 +172,43 @@ You can also run PicoClaw using Docker Compose without installing anything local
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. Set your API keys # 2. First run — auto-generates docker/data/config.json then exits
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc. # The container prints "First-run setup complete." and stops.
# 3. Build & Start # 3. Set your API keys
docker compose --profile gateway up -d vim docker/data/config.json # Set provider API keys, bot tokens, etc.
# 4. Start
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`. > **Docker Users**: By default, the Gateway listens on `127.0.0.1` which is not accessible from the host. If you need to access the health endpoints or expose ports, set `PICOCLAW_GATEWAY_HOST=0.0.0.0` in your environment or update `config.json`.
```bash
# 5. Check logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. Check logs # 6. Stop
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. Stop
docker compose --profile gateway down
``` ```
### Agent Mode (One-shot) ### Agent Mode (One-shot)
```bash ```bash
# Ask a question # Ask a question
docker compose run --rm picoclaw-agent -m "What is 2+2?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "What is 2+2?"
# Interactive mode # Interactive mode
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### Rebuild ### Update
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 Quick Start ### 🚀 Quick Start

View file

@ -165,39 +165,43 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. Configure suas API keys # 2. Primeiro uso — gera docker/data/config.json automaticamente e para
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. # O contêiner exibe "First-run setup complete." e para.
# 3. Build & Iniciar # 3. Configure suas API keys
docker compose --profile gateway up -d vim docker/data/config.json # Chaves de API do provedor, tokens de bot, etc.
# 4. Iniciar
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`. > **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
```bash
# 5. Ver logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. Ver logs # 6. Parar
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. Parar
docker compose --profile gateway down
``` ```
### Modo Agente (Execução única) ### Modo Agente (Execução única)
```bash ```bash
# Fazer uma pergunta # Fazer uma pergunta
docker compose run --rm picoclaw-agent -m "Quanto e 2+2?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Quanto e 2+2?"
# Modo interativo # Modo interativo
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### Rebuild ### Atualizar
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 Início Rápido ### 🚀 Início Rápido

View file

@ -145,39 +145,43 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. Thiết lập API Key # 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v. # Container hiển thị "First-run setup complete." rồi tự dừng.
# 3. Build & Khởi động # 3. Thiết lập API Key
docker compose --profile gateway up -d vim docker/data/config.json # API key của provider, bot token, v.v.
# 4. Khởi động
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`. > **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
```bash
# 5. Xem logs
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 4. Xem logs # 6. Dừng
docker compose logs -f picoclaw-gateway docker compose -f docker/docker-compose.yml --profile gateway down
# 5. Dừng
docker compose --profile gateway down
``` ```
### Chế độ Agent (chạy một lần) ### Chế độ Agent (chạy một lần)
```bash ```bash
# Đặt câu hỏi # Đặt câu hỏi
docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 bằng mấy?"
# Chế độ tương tác # Chế độ tương tác
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### Build lại ### Cập nhật
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 Bắt đầu nhanh ### 🚀 Bắt đầu nhanh

View file

@ -166,41 +166,43 @@ make install
git clone https://github.com/sipeed/picoclaw.git git clone https://github.com/sipeed/picoclaw.git
cd picoclaw cd picoclaw
# 2. 设置 API Key # 2. 首次运行 — 自动生成 docker/data/config.json 后退出
cp config/config.example.json config/config.json docker compose -f docker/docker-compose.yml --profile gateway up
vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等 # 容器打印 "First-run setup complete." 后自动停止
# 3. 构建并启动 # 3. 填写 API Key 等配置
docker compose --profile gateway up -d vim docker/data/config.json # 设置 provider API key、Bot Token 等
# 4. 正式启动
docker compose -f docker/docker-compose.yml --profile gateway up -d
```
> [!TIP] > [!TIP]
**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json` > **Docker 用户**: 默认情况下, Gateway 监听 `127.0.0.1`,该端口不会暴露到容器外。如果需要通过端口映射访问健康检查接口,请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`
# 4. 查看日志 ```bash
docker compose logs -f picoclaw-gateway # 5. 查看日志
docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
# 5. 停止
docker compose --profile gateway down
# 6. 停止
docker compose -f docker/docker-compose.yml --profile gateway down
``` ```
### Agent 模式 (一次性运行) ### Agent 模式 (一次性运行)
```bash ```bash
# 提问 # 提问
docker compose run --rm picoclaw-agent -m "2+2 等于几?" docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "2+2 等于几?"
# 交互模式 # 交互模式
docker compose run --rm picoclaw-agent docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
``` ```
### 重新构建 ### 更新镜像
```bash ```bash
docker compose --profile gateway build --no-cache docker compose -f docker/docker-compose.yml pull
docker compose --profile gateway up -d docker compose -f docker/docker-compose.yml --profile gateway up -d
``` ```
### 🚀 快速开始 ### 🚀 快速开始

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 366 KiB

View file

@ -52,32 +52,41 @@
"proxy": "", "proxy": "",
"allow_from": [ "allow_from": [
"YOUR_USER_ID" "YOUR_USER_ID"
] ],
"reasoning_channel_id": ""
}, },
"discord": { "discord": {
"enabled": false, "enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN", "token": "YOUR_DISCORD_BOT_TOKEN",
"allow_from": [], "allow_from": [],
"mention_only": false "mention_only": false,
"reasoning_channel_id": ""
}, },
"qq": { "qq": {
"enabled": false, "enabled": false,
"app_id": "YOUR_QQ_APP_ID", "app_id": "YOUR_QQ_APP_ID",
"app_secret": "YOUR_QQ_APP_SECRET", "app_secret": "YOUR_QQ_APP_SECRET",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"maixcam": { "maixcam": {
"enabled": false, "enabled": false,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 18790, "port": 18790,
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"whatsapp": { "whatsapp": {
"enabled": false, "enabled": false,
"bridge_url": "ws://localhost:3001", "bridge_url": "ws://localhost:3001",
<<<<<<< main
"use_native": false, "use_native": false,
"session_store_path": "", "session_store_path": "",
"allow_from": [] "allow_from": []
=======
"allow_from": [],
"reasoning_channel_id": ""
>>>>>>> refactor/channel-system
}, },
"feishu": { "feishu": {
"enabled": false, "enabled": false,
@ -85,19 +94,22 @@
"app_secret": "", "app_secret": "",
"encrypt_key": "", "encrypt_key": "",
"verification_token": "", "verification_token": "",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"dingtalk": { "dingtalk": {
"enabled": false, "enabled": false,
"client_id": "YOUR_CLIENT_ID", "client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET", "client_secret": "YOUR_CLIENT_SECRET",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"slack": { "slack": {
"enabled": false, "enabled": false,
"bot_token": "xoxb-YOUR-BOT-TOKEN", "bot_token": "xoxb-YOUR-BOT-TOKEN",
"app_token": "xapp-YOUR-APP-TOKEN", "app_token": "xapp-YOUR-APP-TOKEN",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"line": { "line": {
"enabled": false, "enabled": false,
@ -106,7 +118,8 @@
"webhook_host": "0.0.0.0", "webhook_host": "0.0.0.0",
"webhook_port": 18791, "webhook_port": 18791,
"webhook_path": "/webhook/line", "webhook_path": "/webhook/line",
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"onebot": { "onebot": {
"enabled": false, "enabled": false,
@ -114,7 +127,8 @@
"access_token": "", "access_token": "",
"reconnect_interval": 5, "reconnect_interval": 5,
"group_trigger_prefix": [], "group_trigger_prefix": [],
"allow_from": [] "allow_from": [],
"reasoning_channel_id": ""
}, },
"wecom": { "wecom": {
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats", "_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
@ -126,7 +140,8 @@
"webhook_port": 18793, "webhook_port": 18793,
"webhook_path": "/webhook/wecom", "webhook_path": "/webhook/wecom",
"allow_from": [], "allow_from": [],
"reply_timeout": 5 "reply_timeout": 5,
"reasoning_channel_id": ""
}, },
"wecom_app": { "wecom_app": {
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md", "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
@ -140,7 +155,8 @@
"webhook_port": 18792, "webhook_port": 18792,
"webhook_path": "/webhook/wecom-app", "webhook_path": "/webhook/wecom-app",
"allow_from": [], "allow_from": [],
"reply_timeout": 5 "reply_timeout": 5,
"reasoning_channel_id": ""
} }
}, },
"providers": { "providers": {
@ -253,4 +269,4 @@
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 18790 "port": 18790
} }
} }

View file

@ -5,6 +5,8 @@ ARG TARGETPLATFORM
RUN apk add --no-cache ca-certificates tzdata RUN apk add --no-cache ca-certificates tzdata
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
COPY docker/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["picoclaw"] RUN chmod +x /entrypoint.sh
CMD ["gateway"]
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -1,12 +1,10 @@
services: services:
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# PicoClaw Agent (one-shot query) # PicoClaw Agent (one-shot query)
# docker compose run --rm picoclaw-agent -m "Hello" # docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
picoclaw-agent: picoclaw-agent:
build: image: docker.io/sipeed/picoclaw:latest
context: .
dockerfile: Dockerfile
container_name: picoclaw-agent container_name: picoclaw-agent
profiles: profiles:
- agent - agent
@ -14,33 +12,23 @@ services:
#extra_hosts: #extra_hosts:
# - "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
volumes: volumes:
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro - ./data:/root/.picoclaw
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
entrypoint: ["picoclaw", "agent"] entrypoint: ["picoclaw", "agent"]
stdin_open: true stdin_open: true
tty: true tty: true
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot) # PicoClaw Gateway (Long-running Bot)
# docker compose up picoclaw-gateway # docker compose -f docker/docker-compose.yml up picoclaw-gateway
# ───────────────────────────────────────────── # ─────────────────────────────────────────────
picoclaw-gateway: picoclaw-gateway:
build: image: docker.io/sipeed/picoclaw:latest
context: .
dockerfile: Dockerfile
container_name: picoclaw-gateway container_name: picoclaw-gateway
restart: unless-stopped restart: on-failure
profiles: profiles:
- gateway - gateway
# Uncomment to access host network; leave commented unless needed. # Uncomment to access host network; leave commented unless needed.
#extra_hosts: #extra_hosts:
# - "host.docker.internal:host-gateway" # - "host.docker.internal:host-gateway"
volumes: volumes:
# Configuration file - ./data:/root/.picoclaw
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
# Persistent workspace (sessions, memory, logs)
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
command: ["gateway"]
volumes:
picoclaw-workspace:

15
docker/entrypoint.sh Normal file
View file

@ -0,0 +1,15 @@
#!/bin/sh
set -e
# First-run: neither config nor workspace exists.
# If config.json is already mounted but workspace is missing we skip onboard to
# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container.
if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then
picoclaw onboard
echo ""
echo "First-run setup complete."
echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container."
exit 0
fi
exec picoclaw gateway "$@"

View file

@ -55,6 +55,8 @@ type processOptions struct {
NoHistory bool // If true, don't load session history (for heartbeat) NoHistory bool // If true, don't load session history (for heartbeat)
} }
const defaultResponse = "I've completed processing but have no response to give. Increase `max_tool_iterations` in config.json."
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop { func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
registry := NewAgentRegistry(cfg, provider) registry := NewAgentRegistry(cfg, provider)
@ -330,7 +332,7 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha
Channel: channel, Channel: channel,
ChatID: chatID, ChatID: chatID,
UserMessage: content, UserMessage: content,
DefaultResponse: "I've completed processing but have no response to give.", DefaultResponse: defaultResponse,
EnableSummary: false, EnableSummary: false,
SendResponse: false, SendResponse: false,
NoHistory: true, // Don't load session history for heartbeat NoHistory: true, // Don't load session history for heartbeat
@ -406,7 +408,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Channel: msg.Channel, Channel: msg.Channel,
ChatID: msg.ChatID, ChatID: msg.ChatID,
UserMessage: msg.Content, UserMessage: msg.Content,
DefaultResponse: "I've completed processing but have no response to give.", DefaultResponse: defaultResponse,
EnableSummary: true, EnableSummary: true,
SendResponse: false, SendResponse: false,
}) })
@ -551,6 +553,34 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
return finalContent, nil return finalContent, nil
} }
func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
if al.channelManager == nil {
return ""
}
if ch, ok := al.channelManager.GetChannel(channelName); ok {
return ch.ReasoningChannelID()
}
return ""
}
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) {
if reasoningContent == "" || channelName == "" || channelID == "" {
return
}
// Check context cancellation before attempting to publish,
// since PublishOutbound's select may race between send and ctx.Done().
if ctx.Err() != nil {
return
}
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: channelName,
ChatID: channelID,
Content: reasoningContent,
})
}
// runLLMIteration executes the LLM call loop with tool handling. // runLLMIteration executes the LLM call loop with tool handling.
func (al *AgentLoop) runLLMIteration( func (al *AgentLoop) runLLMIteration(
ctx context.Context, ctx context.Context,
@ -677,6 +707,18 @@ func (al *AgentLoop) runLLMIteration(
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err) return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
} }
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
logger.DebugCF("agent", "LLM response",
map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(response.Content),
"tool_calls": len(response.ToolCalls),
"reasoning": response.Reasoning,
"target_channel": al.targetReasoningChannelID(opts.Channel),
"channel": opts.Channel,
})
// Check if no tool calls - we're done // Check if no tool calls - we're done
if len(response.ToolCalls) == 0 { if len(response.ToolCalls) == 0 {
finalContent = response.Content finalContent = response.Content

View file

@ -9,11 +9,23 @@ import (
"time" "time"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
) )
type fakeChannel struct{ id string }
func (f *fakeChannel) Name() string { return "fake" }
func (f *fakeChannel) Start(ctx context.Context) error { return nil }
func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }
func (f *fakeChannel) IsRunning() bool { return true }
func (f *fakeChannel) IsAllowed(string) bool { return true }
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
func TestRecordLastChannel(t *testing.T) { func TestRecordLastChannel(t *testing.T) {
// Create temp workspace // Create temp workspace
tmpDir, err := os.MkdirTemp("", "agent-test-*") tmpDir, err := os.MkdirTemp("", "agent-test-*")
@ -631,3 +643,158 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory)) t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
} }
} }
func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
if err != nil {
t.Fatalf("Failed to create channel manager: %v", err)
}
for name, id := range map[string]string{
"whatsapp": "rid-whatsapp",
"telegram": "rid-telegram",
"feishu": "rid-feishu",
"discord": "rid-discord",
"maixcam": "rid-maixcam",
"qq": "rid-qq",
"dingtalk": "rid-dingtalk",
"slack": "rid-slack",
"line": "rid-line",
"onebot": "rid-onebot",
"wecom": "rid-wecom",
"wecom_app": "rid-wecom-app",
} {
chManager.RegisterChannel(name, &fakeChannel{id: id})
}
al.SetChannelManager(chManager)
tests := []struct {
channel string
wantID string
}{
{channel: "whatsapp", wantID: "rid-whatsapp"},
{channel: "telegram", wantID: "rid-telegram"},
{channel: "feishu", wantID: "rid-feishu"},
{channel: "discord", wantID: "rid-discord"},
{channel: "maixcam", wantID: "rid-maixcam"},
{channel: "qq", wantID: "rid-qq"},
{channel: "dingtalk", wantID: "rid-dingtalk"},
{channel: "slack", wantID: "rid-slack"},
{channel: "line", wantID: "rid-line"},
{channel: "onebot", wantID: "rid-onebot"},
{channel: "wecom", wantID: "rid-wecom"},
{channel: "wecom_app", wantID: "rid-wecom-app"},
{channel: "unknown", wantID: ""},
}
for _, tt := range tests {
t.Run(tt.channel, func(t *testing.T) {
got := al.targetReasoningChannelID(tt.channel)
if got != tt.wantID {
t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID)
}
})
}
}
func TestHandleReasoning(t *testing.T) {
newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus
}
t.Run("skips when any required field is empty", func(t *testing.T) {
al, msgBus := newLoop(t)
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
t.Fatalf("expected no outbound message, got %+v", msg)
}
})
t.Run("publishes one message for non telegram", func(t *testing.T) {
al, msgBus := newLoop(t)
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
msg, ok := msgBus.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected an outbound message")
}
if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" {
t.Fatalf("unexpected outbound message: %+v", msg)
}
})
t.Run("publishes one message for telegram", func(t *testing.T) {
al, msgBus := newLoop(t)
reasoning := "hello telegram reasoning"
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
msg, ok := msgBus.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected outbound message")
}
if msg.Channel != "telegram" {
t.Fatalf("expected telegram channel message, got %+v", msg)
}
if msg.ChatID != "tg-chat" {
t.Fatalf("expected chatID tg-chat, got %+v", msg)
}
if msg.Content != reasoning {
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
}
})
t.Run("expired ctx", func(t *testing.T) {
al, msgBus := newLoop(t)
reasoning := "hello telegram reasoning"
ctx, cancel := context.WithCancel(context.Background())
cancel()
al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
msg, ok := msgBus.SubscribeOutbound(ctx)
if ok {
t.Fatalf("expected no outbound message, got %+v", msg)
}
})
}

View file

@ -12,6 +12,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil"
) )
// MemoryStore manages persistent memory for the agent. // MemoryStore manages persistent memory for the agent.
@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
// WriteLongTerm writes content to the long-term memory file (MEMORY.md). // WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error { func (ms *MemoryStore) WriteLongTerm(content string) error {
return os.WriteFile(ms.memoryFile, []byte(content), 0o644) // Use unified atomic write utility with explicit sync for flash storage reliability.
// Using 0o600 (owner read/write only) for secure default permissions.
return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
} }
// ReadToday reads today's daily note. // ReadToday reads today's daily note.
@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
// Ensure month directory exists // Ensure month directory exists
monthDir := filepath.Dir(todayFile) monthDir := filepath.Dir(todayFile)
os.MkdirAll(monthDir, 0o755) if err := os.MkdirAll(monthDir, 0o755); err != nil {
return err
}
var existingContent string var existingContent string
if data, err := os.ReadFile(todayFile); err == nil { if data, err := os.ReadFile(todayFile); err == nil {
@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
newContent = existingContent + "\n" + content newContent = existingContent + "\n" + content
} }
return os.WriteFile(todayFile, []byte(newContent), 0o644) // Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
} }
// GetRecentDailyNotes returns daily notes from the last N days. // GetRecentDailyNotes returns daily notes from the last N days.

View file

@ -5,6 +5,8 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil"
) )
type AuthCredential struct { type AuthCredential struct {
@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
func SaveStore(store *AuthStore) error { func SaveStore(store *AuthStore) error {
path := authFilePath() path := authFilePath()
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(store, "", " ") data, err := json.MarshalIndent(store, "", " ")
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(path, data, 0o600)
// Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(path, data, 0o600)
} }
func GetCredential(provider string) (*AuthCredential, error) { func GetCredential(provider string) (*AuthCredential, error) {

View file

@ -34,6 +34,9 @@ func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) er
if mb.closed.Load() { if mb.closed.Load() {
return ErrBusClosed return ErrBusClosed
} }
if err := ctx.Err(); err != nil {
return err
}
select { select {
case mb.inbound <- msg: case mb.inbound <- msg:
return nil return nil
@ -59,6 +62,9 @@ func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage)
if mb.closed.Load() { if mb.closed.Load() {
return ErrBusClosed return ErrBusClosed
} }
if err := ctx.Err(); err != nil {
return err
}
select { select {
case mb.outbound <- msg: case mb.outbound <- msg:
return nil return nil
@ -84,6 +90,9 @@ func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMedi
if mb.closed.Load() { if mb.closed.Load() {
return ErrBusClosed return ErrBusClosed
} }
if err := ctx.Err(); err != nil {
return err
}
select { select {
case mb.outboundMedia <- msg: case mb.outboundMedia <- msg:
return nil return nil

View file

@ -48,6 +48,7 @@ type Channel interface {
IsRunning() bool IsRunning() bool
IsAllowed(senderID string) bool IsAllowed(senderID string) bool
IsAllowedSender(sender bus.SenderInfo) bool IsAllowedSender(sender bus.SenderInfo) bool
ReasoningChannelID() string
} }
// BaseChannelOption is a functional option for configuring a BaseChannel. // BaseChannelOption is a functional option for configuring a BaseChannel.
@ -65,6 +66,11 @@ func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption {
return func(c *BaseChannel) { c.groupTrigger = gt } return func(c *BaseChannel) { c.groupTrigger = gt }
} }
// WithReasoningChannelID sets the reasoning channel ID where thoughts should be sent.
func WithReasoningChannelID(id string) BaseChannelOption {
return func(c *BaseChannel) { c.reasoningChannelID = id }
}
// MessageLengthProvider is an opt-in interface that channels implement // MessageLengthProvider is an opt-in interface that channels implement
// to advertise their maximum message length. The Manager uses this via // to advertise their maximum message length. The Manager uses this via
// type assertion to decide whether to split outbound messages. // type assertion to decide whether to split outbound messages.
@ -83,6 +89,7 @@ type BaseChannel struct {
mediaStore media.MediaStore mediaStore media.MediaStore
placeholderRecorder PlaceholderRecorder placeholderRecorder PlaceholderRecorder
owner Channel // the concrete channel that embeds this BaseChannel owner Channel // the concrete channel that embeds this BaseChannel
reasoningChannelID string
} }
func NewBaseChannel( func NewBaseChannel(
@ -154,6 +161,10 @@ func (c *BaseChannel) Name() string {
return c.name return c.name
} }
func (c *BaseChannel) ReasoningChannelID() string {
return c.reasoningChannelID
}
func (c *BaseChannel) IsRunning() bool { func (c *BaseChannel) IsRunning() bool {
return c.running.Load() return c.running.Load()
} }

View file

@ -42,6 +42,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000), channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &DingTalkChannel{ return &DingTalkChannel{

View file

@ -43,6 +43,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom, base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
channels.WithMaxMessageLength(2000), channels.WithMaxMessageLength(2000),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &DiscordChannel{ return &DiscordChannel{

View file

@ -35,6 +35,7 @@ type FeishuChannel struct {
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) { func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom, base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &FeishuChannel{ return &FeishuChannel{

View file

@ -63,6 +63,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(5000), channels.WithMaxMessageLength(5000),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &LINEChannel{ return &LINEChannel{

View file

@ -33,7 +33,13 @@ type MaixCamMessage struct {
} }
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) { func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
base := channels.NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom) base := channels.NewBaseChannel(
"maixcam",
cfg,
bus,
cfg.AllowFrom,
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &MaixCamChannel{ return &MaixCamChannel{
BaseChannel: base, BaseChannel: base,

View file

@ -99,6 +99,7 @@ type oneBotMessageSegment struct {
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) { func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
const dedupSize = 1024 const dedupSize = 1024

View file

@ -35,6 +35,7 @@ type QQChannel struct {
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) { func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &QQChannel{ return &QQChannel{

View file

@ -51,6 +51,7 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(40000), channels.WithMaxMessageLength(40000),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &SlackChannel{ return &SlackChannel{

View file

@ -84,6 +84,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
telegramCfg.AllowFrom, telegramCfg.AllowFrom,
channels.WithMaxMessageLength(4096), channels.WithMaxMessageLength(4096),
channels.WithGroupTrigger(telegramCfg.GroupTrigger), channels.WithGroupTrigger(telegramCfg.GroupTrigger),
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
) )
return &TelegramChannel{ return &TelegramChannel{

View file

@ -126,6 +126,7 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048), channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &WeComAppChannel{ return &WeComAppChannel{

View file

@ -90,6 +90,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom, base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048), channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger), channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
) )
return &WeComBotChannel{ return &WeComBotChannel{

View file

@ -29,7 +29,14 @@ type WhatsAppChannel struct {
} }
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) { func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
base := channels.NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536)) base := channels.NewBaseChannel(
"whatsapp",
cfg,
bus,
cfg.AllowFrom,
channels.WithMaxMessageLength(65536),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &WhatsAppChannel{ return &WhatsAppChannel{
BaseChannel: base, BaseChannel: base,

View file

@ -4,10 +4,11 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"path/filepath"
"sync/atomic" "sync/atomic"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
"github.com/sipeed/picoclaw/pkg/fileutil"
) )
// rrCounter is a global counter for round-robin load balancing across models. // rrCounter is a global counter for round-robin load balancing across models.
@ -228,68 +229,77 @@ type WhatsAppConfig struct {
UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
} }
type TelegramConfig struct { type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"` Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
} }
type FeishuConfig struct { type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
} }
type DiscordConfig struct { type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"` MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"` Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
} }
type MaixCamConfig struct { type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
} }
type QQConfig struct { type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
} }
type DingTalkConfig struct { type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
} }
type SlackConfig struct { type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"` Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
} }
type LINEConfig struct { type LINEConfig struct {
@ -302,6 +312,8 @@ type LINEConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"` Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
} }
type OneBotConfig struct { type OneBotConfig struct {
@ -313,34 +325,38 @@ type OneBotConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"` Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
} }
type WeComConfig struct { type WeComConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"` EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"` WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"` WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"` WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
} }
type WeComAppConfig struct { type WeComAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"` CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"` CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"` AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"` EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"` WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"` WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"` WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"` ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
} }
type PicoConfig struct { type PicoConfig struct {
@ -627,12 +643,8 @@ func SaveConfig(path string, cfg *Config) error {
return err return err
} }
dir := filepath.Dir(path) // Use unified atomic write utility with explicit sync for flash storage reliability.
if err := os.MkdirAll(dir, 0o755); err != nil { return fileutil.WriteFileAtomic(path, data, 0o600)
return err
}
return os.WriteFile(path, data, 0o600)
} }
func (c *Config) WorkspacePath() string { func (c *Config) WorkspacePath() string {

View file

@ -210,8 +210,8 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
func TestDefaultConfig_Model(t *testing.T) { func TestDefaultConfig_Model(t *testing.T) {
cfg := DefaultConfig() cfg := DefaultConfig()
if cfg.Agents.Defaults.Model == "" { if cfg.Agents.Defaults.Model != "" {
t.Error("Model should not be empty") t.Error("Model should be empty")
} }
} }
@ -331,8 +331,8 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.Workspace == "" { if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty") t.Error("Workspace should not be empty")
} }
if cfg.Agents.Defaults.Model == "" { if cfg.Agents.Defaults.Model != "" {
t.Error("Model should not be empty") t.Error("Model should be empty")
} }
if cfg.Agents.Defaults.Temperature != nil { if cfg.Agents.Defaults.Temperature != nil {
t.Error("Temperature should be nil when not provided") t.Error("Temperature should be nil when not provided")

View file

@ -13,10 +13,10 @@ func DefaultConfig() *Config {
Workspace: "~/.picoclaw/workspace", Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true, RestrictToWorkspace: true,
Provider: "", Provider: "",
Model: "glm-4.7", Model: "",
MaxTokens: 8192, MaxTokens: 32768,
Temperature: nil, // nil means use provider default Temperature: nil, // nil means use provider default
MaxToolIterations: 20, MaxToolIterations: 50,
}, },
}, },
Bindings: []AgentBinding{}, Bindings: []AgentBinding{},

View file

@ -7,11 +7,12 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"path/filepath"
"sync" "sync"
"time" "time"
"github.com/adhocore/gronx" "github.com/adhocore/gronx"
"github.com/sipeed/picoclaw/pkg/fileutil"
) )
type CronSchedule struct { type CronSchedule struct {
@ -330,17 +331,13 @@ func (cs *CronService) loadStore() error {
} }
func (cs *CronService) saveStoreUnsafe() error { func (cs *CronService) saveStoreUnsafe() error {
dir := filepath.Dir(cs.storePath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(cs.store, "", " ") data, err := json.MarshalIndent(cs.store, "", " ")
if err != nil { if err != nil {
return err return err
} }
return os.WriteFile(cs.storePath, data, 0o600) // Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(cs.storePath, data, 0o600)
} }
func (cs *CronService) AddJob( func (cs *CronService) AddJob(

119
pkg/fileutil/file.go Normal file
View file

@ -0,0 +1,119 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
// Package fileutil provides file manipulation utilities.
package fileutil
import (
"fmt"
"os"
"path/filepath"
"time"
)
// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern.
//
// This guarantees that the target file is either:
// - Completely written with the new data
// - Unchanged (if any step fails before rename)
//
// The function:
// 1. Creates a temp file in the same directory (original untouched)
// 2. Writes data to temp file
// 3. Syncs data to disk (critical for SD cards/flash storage)
// 4. Sets file permissions
// 5. Syncs directory metadata (ensures rename is durable)
// 6. Atomically renames temp file to target path
//
// Safety guarantees:
// - Original file is NEVER modified until successful rename
// - Temp file is always cleaned up on error
// - Data is flushed to physical storage before rename
// - Directory entry is synced to prevent orphaned inodes
//
// Parameters:
// - path: Target file path
// - data: Data to write
// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable)
//
// Returns:
// - Error if any step fails, nil on success
//
// Example:
//
// // Secure config file (owner read/write only)
// err := utils.WriteFileAtomic("config.json", data, 0o600)
//
// // Public readable file
// err := utils.WriteFileAtomic("public.txt", data, 0o644)
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// Create temp file in the same directory (ensures atomic rename works)
// Using a hidden prefix (.tmp-) to avoid issues with some tools
tmpFile, err := os.OpenFile(
filepath.Join(dir, fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())),
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
perm,
)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
cleanup := true
defer func() {
if cleanup {
tmpFile.Close()
_ = os.Remove(tmpPath)
}
}()
// Write data to temp file
// Note: Original file is untouched at this point
if _, err := tmpFile.Write(data); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// CRITICAL: Force sync to storage medium before any other operations.
// This ensures data is physically written to disk, not just cached.
// Essential for SD cards, eMMC, and other flash storage on edge devices.
if err := tmpFile.Sync(); err != nil {
return fmt.Errorf("failed to sync temp file: %w", err)
}
// Set file permissions before closing
if err := tmpFile.Chmod(perm); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
}
// Close file before rename (required on Windows)
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
// Atomic rename: temp file becomes the target
// On POSIX: rename() is atomic
// On Windows: Rename() is atomic for files
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("failed to rename temp file: %w", err)
}
// Sync directory to ensure rename is durable
// This prevents the renamed file from disappearing after a crash
if dirFile, err := os.Open(dir); err == nil {
_ = dirFile.Sync()
dirFile.Close()
}
// Success: skip cleanup (file was renamed, no temp to remove)
cleanup = false
return nil
}

View file

@ -17,6 +17,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools" "github.com/sipeed/picoclaw/pkg/tools"
@ -276,7 +277,7 @@ This file contains tasks for the heartbeat service to check periodically.
Add your heartbeat tasks below this line: Add your heartbeat tasks below this line:
` `
if err := os.WriteFile(heartbeatPath, []byte(defaultContent), 0o644); err != nil { if err := fileutil.WriteFileAtomic(heartbeatPath, []byte(defaultContent), 0o644); err != nil {
hs.logErrorf("Failed to create default HEARTBEAT.md: %v", err) hs.logErrorf("Failed to create default HEARTBEAT.md: %v", err)
} else { } else {
hs.logInfof("Created default HEARTBEAT.md template") hs.logInfof("Created default HEARTBEAT.md template")

View file

@ -296,8 +296,8 @@ func TestConvertConfig(t *testing.T) {
if len(warnings) != 0 { if len(warnings) != 0 {
t.Errorf("expected no warnings, got %v", warnings) t.Errorf("expected no warnings, got %v", warnings)
} }
if cfg.Agents.Defaults.Model != "glm-4.7" { if cfg.Agents.Defaults.Model != "" {
t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model) t.Errorf("default model should be nil, got %q", cfg.Agents.Defaults.Model)
} }
}) })
} }

View file

@ -18,9 +18,21 @@ import (
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) { func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
model := cfg.Agents.Defaults.GetModelName() model := cfg.Agents.Defaults.GetModelName()
// Ensure model_list is populated (should be done by LoadConfig, but handle edge cases) // Ensure model_list is populated from providers config if needed
if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() { // This handles two cases:
cfg.ModelList = config.ConvertProvidersToModelList(cfg) // 1. ModelList is empty - convert all providers
// 2. ModelList has some entries but not all providers - merge missing ones
if cfg.HasProvidersConfig() {
providerModels := config.ConvertProvidersToModelList(cfg)
existingModelNames := make(map[string]bool)
for _, m := range cfg.ModelList {
existingModelNames[m.ModelName] = true
}
for _, pm := range providerModels {
if !existingModelNames[pm.ModelName] {
cfg.ModelList = append(cfg.ModelList, pm)
}
}
} }
// Must have model_list at this point // Must have model_list at this point

View file

@ -25,6 +25,7 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra GoogleExtra = protocoltypes.GoogleExtra
ReasoningDetail = protocoltypes.ReasoningDetail
) )
type Provider struct { type Provider struct {
@ -198,8 +199,10 @@ func parseResponse(body []byte) (*LLMResponse, error) {
var apiResponse struct { var apiResponse struct {
Choices []struct { Choices []struct {
Message struct { Message struct {
Content string `json:"content"` Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"` ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
ToolCalls []struct { ToolCalls []struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"` Type string `json:"type"`
@ -274,6 +277,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
return &LLMResponse{ return &LLMResponse{
Content: choice.Message.Content, Content: choice.Message.Content,
ReasoningContent: choice.Message.ReasoningContent, ReasoningContent: choice.Message.ReasoningContent,
Reasoning: choice.Message.Reasoning,
ReasoningDetails: choice.Message.ReasoningDetails,
ToolCalls: toolCalls, ToolCalls: toolCalls,
FinishReason: choice.FinishReason, FinishReason: choice.FinishReason,
Usage: apiResponse.Usage, Usage: apiResponse.Usage,

View file

@ -25,11 +25,20 @@ type FunctionCall struct {
} }
type LLMResponse struct { type LLMResponse struct {
Content string `json:"content"` Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` ToolCalls []ToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason"` FinishReason string `json:"finish_reason"`
Usage *UsageInfo `json:"usage,omitempty"` Usage *UsageInfo `json:"usage,omitempty"`
Reasoning string `json:"reasoning"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
}
type ReasoningDetail struct {
Format string `json:"format"`
Index int `json:"index"`
Type string `json:"type"`
Text string `json:"text"`
} }
type UsageInfo struct { type UsageInfo struct {

View file

@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
) )
@ -66,7 +67,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
} }
skillPath := filepath.Join(skillDir, "SKILL.md") skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, body, 0o644); err != nil {
// Use unified atomic write utility with explicit sync for flash storage reliability.
if err := fileutil.WriteFileAtomic(skillPath, body, 0o600); err != nil {
return fmt.Errorf("failed to write skill file: %w", err) return fmt.Errorf("failed to write skill file: %w", err)
} }

View file

@ -8,6 +8,8 @@ import (
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil"
) )
// State represents the persistent state for a workspace. // State represents the persistent state for a workspace.
@ -124,33 +126,20 @@ func (sm *Manager) GetTimestamp() time.Time {
// saveAtomic performs an atomic save using temp file + rename. // saveAtomic performs an atomic save using temp file + rename.
// This ensures that the state file is never corrupted: // This ensures that the state file is never corrupted:
// 1. Write to a temp file // 1. Write to a temp file
// 2. Rename temp file to target (atomic on POSIX systems) // 2. Sync to disk (critical for SD cards/flash storage)
// 3. If rename fails, cleanup the temp file // 3. Rename temp file to target (atomic on POSIX systems)
// 4. If rename fails, cleanup the temp file
// //
// Must be called with the lock held. // Must be called with the lock held.
func (sm *Manager) saveAtomic() error { func (sm *Manager) saveAtomic() error {
// Create temp file in the same directory as the target // Use unified atomic write utility with explicit sync for flash storage reliability.
tempFile := sm.stateFile + ".tmp" // Using 0o600 (owner read/write only) for secure default permissions.
// Marshal state to JSON
data, err := json.MarshalIndent(sm.state, "", " ") data, err := json.MarshalIndent(sm.state, "", " ")
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal state: %w", err) return fmt.Errorf("failed to marshal state: %w", err)
} }
// Write to temp file return fileutil.WriteFileAtomic(sm.stateFile, data, 0o600)
if err := os.WriteFile(tempFile, data, 0o644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// Atomic rename from temp to target
if err := os.Rename(tempFile, sm.stateFile); err != nil {
// Cleanup temp file if rename fails
os.Remove(tempFile)
return fmt.Errorf("failed to rename temp file: %w", err)
}
return nil
} }
// load loads the state from disk. // load loads the state from disk.

View file

@ -8,6 +8,8 @@ import (
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil"
) )
// validatePath ensures the given path is within the workspace if restrict is true. // validatePath ensures the given path is within the workspace if restrict is true.
@ -276,25 +278,9 @@ func (h *hostFs) ReadDir(path string) ([]os.DirEntry, error) {
} }
func (h *hostFs) WriteFile(path string, data []byte) error { func (h *hostFs) WriteFile(path string, data []byte) error {
dir := filepath.Dir(path) // Use unified atomic write utility with explicit sync for flash storage reliability.
if err := os.MkdirAll(dir, 0o755); err != nil { // Using 0o600 (owner read/write only) for secure default permissions.
return fmt.Errorf("failed to create parent directories: %w", err) return fileutil.WriteFileAtomic(path, data, 0o600)
}
// We use a "write-then-rename" pattern here to ensure an atomic write.
// This prevents the target file from being left in a truncated or partial state
// if the operation is interrupted, as the rename operation is atomic on Linux.
tmpPath := fmt.Sprintf("%s.%d.tmp", path, time.Now().UnixNano())
if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
os.Remove(tmpPath) // Ensure cleanup of partial/empty temp file
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to replace original file: %w", err)
}
return nil
} }
// sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root. // sandboxFs is a sandboxed fileSystem that operates within a strictly defined workspace using os.Root.
@ -351,20 +337,46 @@ func (r *sandboxFs) WriteFile(path string, data []byte) error {
} }
} }
// We use a "write-then-rename" pattern here to ensure an atomic write. // Use atomic write pattern with explicit sync for flash storage reliability.
// This prevents the target file from being left in a truncated or partial state // Using 0o600 (owner read/write only) for secure default permissions.
// if the operation is interrupted, as the rename operation is atomic on Linux. tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())
tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil { tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file if err != nil {
return fmt.Errorf("failed to write to temp file: %w", err) root.Remove(tmpRelPath)
return fmt.Errorf("failed to open temp file: %w", err)
}
if _, err := tmpFile.Write(data); err != nil {
tmpFile.Close()
root.Remove(tmpRelPath)
return fmt.Errorf("failed to write temp file: %w", err)
}
// CRITICAL: Force sync to storage medium before rename.
// This ensures data is physically written to disk, not just cached.
if err := tmpFile.Sync(); err != nil {
tmpFile.Close()
root.Remove(tmpRelPath)
return fmt.Errorf("failed to sync temp file: %w", err)
}
if err := tmpFile.Close(); err != nil {
root.Remove(tmpRelPath)
return fmt.Errorf("failed to close temp file: %w", err)
} }
if err := root.Rename(tmpRelPath, relPath); err != nil { if err := root.Rename(tmpRelPath, relPath); err != nil {
root.Remove(tmpRelPath) root.Remove(tmpRelPath)
return fmt.Errorf("failed to rename temp file over target: %w", err) return fmt.Errorf("failed to rename temp file over target: %w", err)
} }
// Sync directory to ensure rename is durable
if dirFile, err := root.Open("."); err == nil {
_ = dirFile.Sync()
dirFile.Close()
}
return nil return nil
}) })
} }

View file

@ -9,6 +9,7 @@ import (
"sync" "sync"
"time" "time"
"github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils" "github.com/sipeed/picoclaw/pkg/utils"
@ -197,5 +198,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
return err return err
} }
return os.WriteFile(filepath.Join(targetDir, ".skill-origin.json"), data, 0o644) // Use unified atomic write utility with explicit sync for flash storage reliability.
return fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-origin.json"), data, 0o600)
} }