diff --git a/.goreleaser.yaml b/.goreleaser.yaml
index 23e422ab1..49d230506 100644
--- a/.goreleaser.yaml
+++ b/.goreleaser.yaml
@@ -5,7 +5,7 @@ version: 2
before:
hooks:
- go mod tidy
- - go generate ./cmd/picoclaw
+ - go generate ./cmd/picoclaw/...
builds:
- id: picoclaw
@@ -39,7 +39,9 @@ builds:
dockers_v2:
- id: picoclaw
- dockerfile: Dockerfile.goreleaser
+ dockerfile: docker/Dockerfile.goreleaser
+ extra_files:
+ - docker/entrypoint.sh
ids:
- picoclaw
images:
@@ -73,7 +75,6 @@ nfpms:
package_name: picoclaw
file_name_template: >-
{{ .PackageName }}_
- {{- .Version }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "arm64" }}aarch64
{{- else if eq .Arch "arm" }}armv{{ .Arm }}
diff --git a/Makefile b/Makefile
index c59c414f3..a14723616 100644
--- a/Makefile
+++ b/Makefile
@@ -44,6 +44,8 @@ ifeq ($(UNAME_S),Linux)
ARCH=amd64
else ifeq ($(UNAME_M),aarch64)
ARCH=arm64
+ else ifeq ($(UNAME_M),armv81)
+ ARCH=arm64
else ifeq ($(UNAME_M),loongarch64)
ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
diff --git a/README.fr.md b/README.fr.md
index f59807739..c452b71ac 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -164,39 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. Configurez vos clés API
-cp config/config.example.json config/config.json
-vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
+# 2. Premier lancement — génère docker/data/config.json puis s'arrête
+docker compose -f docker/docker-compose.yml --profile gateway up
+# Le conteneur affiche "First-run setup complete." puis s'arrête.
-# 3. Compiler & Démarrer
-docker compose --profile gateway up -d
+# 3. Configurez vos clés API
+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]
> **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
-docker compose logs -f picoclaw-gateway
-
-# 5. Arrêter
-docker compose --profile gateway down
+# 6. Arrêter
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Mode Agent (exécution unique)
```bash
# 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
-docker compose run --rm picoclaw-agent
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
-### Recompiler
+### Mettre à jour
```bash
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Démarrage Rapide
@@ -221,6 +225,7 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
@@ -252,6 +257,9 @@ picoclaw onboard
}
```
+> **Nouveau** : Le format de configuration `model_list` permet d'ajouter des fournisseurs sans modifier le code. Voir [Configuration de Modèle](#configuration-de-modèle-model_list) pour plus de détails.
+> `request_timeout` est optionnel et s'exprime en secondes. S'il est omis ou défini à `<= 0`, PicoClaw utilise le délai d'expiration par défaut (120s).
+
**3. Obtenir des Clés API**
* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@@ -979,6 +987,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
```
> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
+**Proxy/API personnalisée**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### Équilibrage de Charge
Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
diff --git a/README.ja.md b/README.ja.md
index 5a7bb8542..6d5d09451 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -126,39 +126,43 @@ Docker Compose を使えば、ローカルにインストールせずに PicoCla
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. API キーを設定
-cp config/config.example.json config/config.json
-vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
+# 2. 初回起動 — docker/data/config.json を自動生成して終了
+docker compose -f docker/docker-compose.yml --profile gateway up
+# コンテナが "First-run setup complete." を表示して停止します。
-# 3. ビルドと起動
-docker compose --profile gateway up -d
+# 3. API キーを設定
+vim docker/data/config.json # プロバイダー API キー、Bot トークンなどを設定
+
+# 4. 起動
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
> [!TIP]
> **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. ログ確認
-docker compose logs -f picoclaw-gateway
-
-# 5. 停止
-docker compose --profile gateway down
+# 6. 停止
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Agent モード(ワンショット)
```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
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 クイックスタート(ネイティブ)
@@ -183,6 +187,7 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
@@ -221,6 +226,9 @@ picoclaw onboard
}
```
+> **新機能**: `model_list` 形式により、プロバイダーをコード変更なしで追加できます。詳細は [モデル設定](#モデル設定-model_list) を参照してください。
+> `request_timeout` は任意の秒単位設定です。省略または `<= 0` の場合、PicoClaw はデフォルトのタイムアウト(120秒)を使用します。
+
**3. API キーの取得**
- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@@ -918,6 +926,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
```
> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
+**カスタムプロキシ/API**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### ロードバランシング
同じモデル名で複数のエンドポイントを設定すると、PicoClaw が自動的にラウンドロビンで分散します:
diff --git a/README.md b/README.md
index aa7b0719a..6f5cce4d0 100644
--- a/README.md
+++ b/README.md
@@ -167,39 +167,43 @@ You can also run PicoClaw using Docker Compose without installing anything local
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. Set your API keys
-cp config/config.example.json config/config.json
-vim config/config.json # Set DISCORD_BOT_TOKEN, API keys, etc.
+# 2. First run — auto-generates docker/data/config.json then exits
+docker compose -f docker/docker-compose.yml --profile gateway up
+# The container prints "First-run setup complete." and stops.
-# 3. Build & Start
-docker compose --profile gateway up -d
+# 3. Set your API keys
+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]
> **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
-docker compose logs -f picoclaw-gateway
-
-# 5. Stop
-docker compose --profile gateway down
+# 6. Stop
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Agent Mode (One-shot)
```bash
# 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
-docker compose run --rm picoclaw-agent
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
-### Rebuild
+### Update
```bash
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Quick Start
@@ -232,7 +236,8 @@ picoclaw onboard
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
- "api_key": "your-api-key"
+ "api_key": "your-api-key",
+ "request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
@@ -262,6 +267,7 @@ picoclaw onboard
```
> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
+> `request_timeout` is optional and uses seconds. If omitted or set to `<= 0`, PicoClaw uses the default timeout (120s).
**3. Get API Keys**
@@ -915,7 +921,8 @@ This design also enables **multi-agent support** with flexible provider selectio
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
- "api_key": "sk-..."
+ "api_key": "sk-...",
+ "request_timeout": 300
}
```
diff --git a/README.pt-br.md b/README.pt-br.md
index 0115b7f89..61663e363 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -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
cd picoclaw
-# 2. Configure suas API keys
-cp config/config.example.json config/config.json
-vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
+# 2. Primeiro uso — gera docker/data/config.json automaticamente e para
+docker compose -f docker/docker-compose.yml --profile gateway up
+# O contêiner exibe "First-run setup complete." e para.
-# 3. Build & Iniciar
-docker compose --profile gateway up -d
+# 3. Configure suas API keys
+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]
> **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
-docker compose logs -f picoclaw-gateway
-
-# 5. Parar
-docker compose --profile gateway down
+# 6. Parar
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Modo Agente (Execução única)
```bash
# 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
-docker compose run --rm picoclaw-agent
+docker compose -f docker/docker-compose.yml run --rm picoclaw-agent
```
-### Rebuild
+### Atualizar
```bash
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Início Rápido
@@ -222,6 +226,7 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
@@ -246,6 +251,9 @@ picoclaw onboard
}
```
+> **Novo**: O formato de configuração `model_list` permite adicionar provedores sem alterar código. Veja [Configuração de Modelo](#configuração-de-modelo-model_list) para detalhes.
+> `request_timeout` é opcional e usa segundos. Se omitido ou definido como `<= 0`, o PicoClaw usa o timeout padrão (120s).
+
**3. Obter API Keys**
* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@@ -973,6 +981,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
```
> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
+**Proxy/API personalizada**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### Balanceamento de Carga
Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
diff --git a/README.vi.md b/README.vi.md
index 015bc264e..f8ece7eda 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -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
cd picoclaw
-# 2. Thiết lập API Key
-cp config/config.example.json config/config.json
-vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
+# 2. Lần chạy đầu tiên — tự tạo docker/data/config.json rồi dừng lại
+docker compose -f docker/docker-compose.yml --profile gateway up
+# Container hiển thị "First-run setup complete." rồi tự dừng.
-# 3. Build & Khởi động
-docker compose --profile gateway up -d
+# 3. Thiết lập API Key
+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]
> **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
-docker compose logs -f picoclaw-gateway
-
-# 5. Dừng
-docker compose --profile gateway down
+# 6. Dừng
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Chế độ Agent (chạy một lần)
```bash
# Đặ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
-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
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 Bắt đầu nhanh
@@ -202,6 +206,7 @@ picoclaw onboard
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"api_key": "sk-your-openai-key",
+ "request_timeout": 300,
"api_base": "https://api.openai.com/v1"
}
],
@@ -220,6 +225,9 @@ picoclaw onboard
}
```
+> **Mới**: Định dạng cấu hình `model_list` cho phép thêm nhà cung cấp mà không cần thay đổi mã nguồn. Xem [Cấu hình Mô hình](#cấu-hình-mô-hình-model_list) để biết chi tiết.
+> `request_timeout` là tùy chọn và dùng đơn vị giây. Nếu bỏ qua hoặc đặt `<= 0`, PicoClaw sẽ dùng timeout mặc định (120s).
+
**3. Lấy API Key**
* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
@@ -944,6 +952,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
```
> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
+**Proxy/API tùy chỉnh**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-...",
+ "request_timeout": 300
+}
+```
+
#### Cân bằng Tải tải
Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
diff --git a/README.zh.md b/README.zh.md
index 4f4bde46a..7c9351cb4 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -166,41 +166,43 @@ make install
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
-# 2. 设置 API Key
-cp config/config.example.json config/config.json
-vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
+# 2. 首次运行 — 自动生成 docker/data/config.json 后退出
+docker compose -f docker/docker-compose.yml --profile gateway up
+# 容器打印 "First-run setup complete." 后自动停止
-# 3. 构建并启动
-docker compose --profile gateway up -d
+# 3. 填写 API Key 等配置
+vim docker/data/config.json # 设置 provider API key、Bot Token 等
+
+# 4. 正式启动
+docker compose -f docker/docker-compose.yml --profile gateway up -d
+```
> [!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. 查看日志
-docker compose logs -f picoclaw-gateway
-
-# 5. 停止
-docker compose --profile gateway down
+```bash
+# 5. 查看日志
+docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
+# 6. 停止
+docker compose -f docker/docker-compose.yml --profile gateway down
```
### Agent 模式 (一次性运行)
```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
-docker compose --profile gateway build --no-cache
-docker compose --profile gateway up -d
-
+docker compose -f docker/docker-compose.yml pull
+docker compose -f docker/docker-compose.yml --profile gateway up -d
```
### 🚀 快速开始
@@ -234,7 +236,8 @@ picoclaw onboard
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
- "api_key": "your-api-key"
+ "api_key": "your-api-key",
+ "request_timeout": 300
},
{
"model_name": "claude-sonnet-4.6",
@@ -263,6 +266,7 @@ picoclaw onboard
```
> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
+> `request_timeout` 为可选项,单位为秒。若省略或设置为 `<= 0`,PicoClaw 使用默认超时(120 秒)。
**3. 获取 API Key**
@@ -550,7 +554,8 @@ Agent 读取 HEARTBEAT.md
"model_name": "my-custom-model",
"model": "openai/custom-model",
"api_base": "https://my-proxy.com/v1",
- "api_key": "sk-..."
+ "api_key": "sk-...",
+ "request_timeout": 300
}
```
diff --git a/assets/picoclaw_detect_person.mp4 b/assets/picoclaw_detect_person.mp4
deleted file mode 100644
index b56999689..000000000
Binary files a/assets/picoclaw_detect_person.mp4 and /dev/null differ
diff --git a/assets/wechat.png b/assets/wechat.png
index 776c07885..1900c7556 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/Dockerfile b/docker/Dockerfile
similarity index 100%
rename from Dockerfile
rename to docker/Dockerfile
diff --git a/Dockerfile.goreleaser b/docker/Dockerfile.goreleaser
similarity index 58%
rename from Dockerfile.goreleaser
rename to docker/Dockerfile.goreleaser
index 0cdc8c6bd..68a02aae8 100644
--- a/Dockerfile.goreleaser
+++ b/docker/Dockerfile.goreleaser
@@ -5,6 +5,8 @@ ARG TARGETPLATFORM
RUN apk add --no-cache ca-certificates tzdata
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
+COPY docker/entrypoint.sh /entrypoint.sh
-ENTRYPOINT ["picoclaw"]
-CMD ["gateway"]
+RUN chmod +x /entrypoint.sh
+
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/docker-compose.yml b/docker/docker-compose.yml
similarity index 64%
rename from docker-compose.yml
rename to docker/docker-compose.yml
index c268b01cd..9ec71abab 100644
--- a/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,12 +1,10 @@
services:
# ─────────────────────────────────────────────
# 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:
- build:
- context: .
- dockerfile: Dockerfile
+ image: docker.io/sipeed/picoclaw:latest
container_name: picoclaw-agent
profiles:
- agent
@@ -14,33 +12,23 @@ services:
#extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
- - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
- - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
+ - ./data:/root/.picoclaw
entrypoint: ["picoclaw", "agent"]
stdin_open: true
tty: true
# ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot)
- # docker compose up picoclaw-gateway
+ # docker compose -f docker/docker-compose.yml up picoclaw-gateway
# ─────────────────────────────────────────────
picoclaw-gateway:
- build:
- context: .
- dockerfile: Dockerfile
+ image: docker.io/sipeed/picoclaw:latest
container_name: picoclaw-gateway
- restart: unless-stopped
+ restart: on-failure
profiles:
- gateway
# Uncomment to access host network; leave commented unless needed.
#extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
- # Configuration file
- - ./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:
+ - ./data:/root/.picoclaw
diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh
new file mode 100644
index 000000000..b6fc724b5
--- /dev/null
+++ b/docker/entrypoint.sh
@@ -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 "$@"
diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md
index 589dfc043..0d4af719c 100644
--- a/docs/migration/model-list-migration.md
+++ b/docs/migration/model-list-migration.md
@@ -117,6 +117,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
| `rpm` | No | Requests per minute limit |
| `max_tokens_field` | No | Field name for max tokens |
+| `request_timeout` | No | HTTP request timeout in seconds; `<=0` uses default `120s` |
*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index d525dfebc..d59e75eda 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -66,6 +66,8 @@ type processOptions struct {
WorkingDir string // Current working directory override (for hipico from cmd mode)
}
+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 {
registry := NewAgentRegistry(cfg, provider)
@@ -313,7 +315,7 @@ func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, cha
Channel: channel,
ChatID: chatID,
UserMessage: content,
- DefaultResponse: "I've completed processing but have no response to give.",
+ DefaultResponse: defaultResponse,
EnableSummary: false,
SendResponse: false,
NoHistory: true, // Don't load session history for heartbeat
diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go
index dd5f4441c..87a687479 100644
--- a/pkg/agent/memory.go
+++ b/pkg/agent/memory.go
@@ -12,6 +12,8 @@ import (
"path/filepath"
"strings"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
// 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).
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.
@@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
// Ensure month directory exists
monthDir := filepath.Dir(todayFile)
- os.MkdirAll(monthDir, 0o755)
+ if err := os.MkdirAll(monthDir, 0o755); err != nil {
+ return err
+ }
var existingContent string
if data, err := os.ReadFile(todayFile); err == nil {
@@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
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.
diff --git a/pkg/auth/store.go b/pkg/auth/store.go
index 64708421b..283dc6977 100644
--- a/pkg/auth/store.go
+++ b/pkg/auth/store.go
@@ -5,6 +5,8 @@ import (
"os"
"path/filepath"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
type AuthCredential struct {
@@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
func SaveStore(store *AuthStore) error {
path := authFilePath()
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return err
- }
-
data, err := json.MarshalIndent(store, "", " ")
if err != nil {
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) {
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index 524494849..6592d9bc0 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -23,6 +23,19 @@ import (
"github.com/sipeed/picoclaw/pkg/voice"
)
+var (
+ reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
+ reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
+ reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
+ reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
+ reBoldUnder = regexp.MustCompile(`__(.+?)__`)
+ reItalic = regexp.MustCompile(`_([^_]+)_`)
+ reStrike = regexp.MustCompile(`~~(.+?)~~`)
+ reListItem = regexp.MustCompile(`^[-*]\s+`)
+ reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
+ reInlineCode = regexp.MustCompile("`([^`]+)`")
+)
+
type TelegramChannel struct {
*BaseChannel
bot *telego.Bot
@@ -431,19 +444,18 @@ func markdownToTelegramHTML(text string) string {
inlineCodes := extractInlineCodes(text)
text = inlineCodes.text
- text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")
+ text = reHeading.ReplaceAllString(text, "$1")
- text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1")
+ text = reBlockquote.ReplaceAllString(text, "$1")
text = escapeHTML(text)
- text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `$1`)
+ text = reLink.ReplaceAllString(text, `$1`)
- text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "$1")
+ text = reBoldStar.ReplaceAllString(text, "$1")
- text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "$1")
+ text = reBoldUnder.ReplaceAllString(text, "$1")
- reItalic := regexp.MustCompile(`_([^_]+)_`)
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
match := reItalic.FindStringSubmatch(s)
if len(match) < 2 {
@@ -452,9 +464,9 @@ func markdownToTelegramHTML(text string) string {
return "" + match[1] + ""
})
- text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "$1")
+ text = reStrike.ReplaceAllString(text, "$1")
- text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ")
+ text = reListItem.ReplaceAllString(text, "• ")
for i, code := range inlineCodes.codes {
escaped := escapeHTML(code)
@@ -479,8 +491,7 @@ type codeBlockMatch struct {
}
func extractCodeBlocks(text string) codeBlockMatch {
- re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
- matches := re.FindAllStringSubmatch(text, -1)
+ matches := reCodeBlock.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
@@ -488,7 +499,7 @@ func extractCodeBlocks(text string) codeBlockMatch {
}
i := 0
- text = re.ReplaceAllStringFunc(text, func(m string) string {
+ text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
i++
return placeholder
@@ -503,8 +514,7 @@ type inlineCodeMatch struct {
}
func extractInlineCodes(text string) inlineCodeMatch {
- re := regexp.MustCompile("`([^`]+)`")
- matches := re.FindAllStringSubmatch(text, -1)
+ matches := reInlineCode.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
@@ -512,7 +522,7 @@ func extractInlineCodes(text string) inlineCodeMatch {
}
i := 0
- text = re.ReplaceAllStringFunc(text, func(m string) string {
+ text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
i++
return placeholder
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 6f76614cf..ca5803c35 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -4,10 +4,11 @@ import (
"encoding/json"
"fmt"
"os"
- "path/filepath"
"sync/atomic"
"github.com/caarlos0/env/v11"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
// rrCounter is a global counter for round-robin load balancing across models.
@@ -371,11 +372,12 @@ func (p ProvidersConfig) MarshalJSON() ([]byte, error) {
}
type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ RequestTimeout int `json:"request_timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_REQUEST_TIMEOUT"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
}
type OpenAIProviderConfig struct {
@@ -406,6 +408,7 @@ type ModelConfig struct {
// Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
+ RequestTimeout int `json:"request_timeout,omitempty"`
}
// Validate checks if the ModelConfig has all required fields.
@@ -553,12 +556,8 @@ func SaveConfig(path string, cfg *Config) error {
return err
}
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- 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 (c *Config) WorkspacePath() string {
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 223ac798d..bf56b7f34 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -413,3 +413,12 @@ func TestLoadConfig_WebToolsProxy(t *testing.T) {
t.Fatalf("Tools.Web.Proxy = %q, want %q", cfg.Tools.Web.Proxy, "http://127.0.0.1:7890")
}
}
+
+// TestDefaultConfig_DMScope verifies the default dm_scope value
+func TestDefaultConfig_DMScope(t *testing.T) {
+ cfg := DefaultConfig()
+
+ if cfg.Session.DMScope != "per-channel-peer" {
+ t.Errorf("Session.DMScope = %q, want 'per-channel-peer'", cfg.Session.DMScope)
+ }
+}
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index cc6de9399..6805a8bc4 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -16,12 +16,12 @@ func DefaultConfig() *Config {
Model: "glm-4.7",
MaxTokens: 8192,
Temperature: nil, // nil means use provider default
- MaxToolIterations: 20,
+ MaxToolIterations: 50,
},
},
Bindings: []AgentBinding{},
Session: SessionConfig{
- DMScope: "main",
+ DMScope: "per-channel-peer",
},
Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index 70e1de438..5deb09270 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -60,12 +60,13 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "openai",
- Model: "openai/gpt-5.2",
- APIKey: p.OpenAI.APIKey,
- APIBase: p.OpenAI.APIBase,
- Proxy: p.OpenAI.Proxy,
- AuthMethod: p.OpenAI.AuthMethod,
+ ModelName: "openai",
+ Model: "openai/gpt-5.2",
+ APIKey: p.OpenAI.APIKey,
+ APIBase: p.OpenAI.APIBase,
+ Proxy: p.OpenAI.Proxy,
+ RequestTimeout: p.OpenAI.RequestTimeout,
+ AuthMethod: p.OpenAI.AuthMethod,
}, true
},
},
@@ -77,12 +78,13 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "anthropic",
- Model: "anthropic/claude-sonnet-4.6",
- APIKey: p.Anthropic.APIKey,
- APIBase: p.Anthropic.APIBase,
- Proxy: p.Anthropic.Proxy,
- AuthMethod: p.Anthropic.AuthMethod,
+ ModelName: "anthropic",
+ Model: "anthropic/claude-sonnet-4.6",
+ APIKey: p.Anthropic.APIKey,
+ APIBase: p.Anthropic.APIBase,
+ Proxy: p.Anthropic.Proxy,
+ RequestTimeout: p.Anthropic.RequestTimeout,
+ AuthMethod: p.Anthropic.AuthMethod,
}, true
},
},
@@ -94,11 +96,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "openrouter",
- Model: "openrouter/auto",
- APIKey: p.OpenRouter.APIKey,
- APIBase: p.OpenRouter.APIBase,
- Proxy: p.OpenRouter.Proxy,
+ ModelName: "openrouter",
+ Model: "openrouter/auto",
+ APIKey: p.OpenRouter.APIKey,
+ APIBase: p.OpenRouter.APIBase,
+ Proxy: p.OpenRouter.Proxy,
+ RequestTimeout: p.OpenRouter.RequestTimeout,
}, true
},
},
@@ -110,11 +113,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "groq",
- Model: "groq/llama-3.1-70b-versatile",
- APIKey: p.Groq.APIKey,
- APIBase: p.Groq.APIBase,
- Proxy: p.Groq.Proxy,
+ ModelName: "groq",
+ Model: "groq/llama-3.1-70b-versatile",
+ APIKey: p.Groq.APIKey,
+ APIBase: p.Groq.APIBase,
+ Proxy: p.Groq.Proxy,
+ RequestTimeout: p.Groq.RequestTimeout,
}, true
},
},
@@ -126,11 +130,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "zhipu",
- Model: "zhipu/glm-4",
- APIKey: p.Zhipu.APIKey,
- APIBase: p.Zhipu.APIBase,
- Proxy: p.Zhipu.Proxy,
+ ModelName: "zhipu",
+ Model: "zhipu/glm-4",
+ APIKey: p.Zhipu.APIKey,
+ APIBase: p.Zhipu.APIBase,
+ Proxy: p.Zhipu.Proxy,
+ RequestTimeout: p.Zhipu.RequestTimeout,
}, true
},
},
@@ -142,11 +147,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "vllm",
- Model: "vllm/auto",
- APIKey: p.VLLM.APIKey,
- APIBase: p.VLLM.APIBase,
- Proxy: p.VLLM.Proxy,
+ ModelName: "vllm",
+ Model: "vllm/auto",
+ APIKey: p.VLLM.APIKey,
+ APIBase: p.VLLM.APIBase,
+ Proxy: p.VLLM.Proxy,
+ RequestTimeout: p.VLLM.RequestTimeout,
}, true
},
},
@@ -158,11 +164,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "gemini",
- Model: "gemini/gemini-pro",
- APIKey: p.Gemini.APIKey,
- APIBase: p.Gemini.APIBase,
- Proxy: p.Gemini.Proxy,
+ ModelName: "gemini",
+ Model: "gemini/gemini-pro",
+ APIKey: p.Gemini.APIKey,
+ APIBase: p.Gemini.APIBase,
+ Proxy: p.Gemini.Proxy,
+ RequestTimeout: p.Gemini.RequestTimeout,
}, true
},
},
@@ -174,11 +181,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "nvidia",
- Model: "nvidia/meta/llama-3.1-8b-instruct",
- APIKey: p.Nvidia.APIKey,
- APIBase: p.Nvidia.APIBase,
- Proxy: p.Nvidia.Proxy,
+ ModelName: "nvidia",
+ Model: "nvidia/meta/llama-3.1-8b-instruct",
+ APIKey: p.Nvidia.APIKey,
+ APIBase: p.Nvidia.APIBase,
+ Proxy: p.Nvidia.Proxy,
+ RequestTimeout: p.Nvidia.RequestTimeout,
}, true
},
},
@@ -190,11 +198,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "ollama",
- Model: "ollama/llama3",
- APIKey: p.Ollama.APIKey,
- APIBase: p.Ollama.APIBase,
- Proxy: p.Ollama.Proxy,
+ ModelName: "ollama",
+ Model: "ollama/llama3",
+ APIKey: p.Ollama.APIKey,
+ APIBase: p.Ollama.APIBase,
+ Proxy: p.Ollama.Proxy,
+ RequestTimeout: p.Ollama.RequestTimeout,
}, true
},
},
@@ -206,11 +215,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "moonshot",
- Model: "moonshot/kimi",
- APIKey: p.Moonshot.APIKey,
- APIBase: p.Moonshot.APIBase,
- Proxy: p.Moonshot.Proxy,
+ ModelName: "moonshot",
+ Model: "moonshot/kimi",
+ APIKey: p.Moonshot.APIKey,
+ APIBase: p.Moonshot.APIBase,
+ Proxy: p.Moonshot.Proxy,
+ RequestTimeout: p.Moonshot.RequestTimeout,
}, true
},
},
@@ -222,11 +232,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "shengsuanyun",
- Model: "shengsuanyun/auto",
- APIKey: p.ShengSuanYun.APIKey,
- APIBase: p.ShengSuanYun.APIBase,
- Proxy: p.ShengSuanYun.Proxy,
+ ModelName: "shengsuanyun",
+ Model: "shengsuanyun/auto",
+ APIKey: p.ShengSuanYun.APIKey,
+ APIBase: p.ShengSuanYun.APIBase,
+ Proxy: p.ShengSuanYun.Proxy,
+ RequestTimeout: p.ShengSuanYun.RequestTimeout,
}, true
},
},
@@ -238,11 +249,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "deepseek",
- Model: "deepseek/deepseek-chat",
- APIKey: p.DeepSeek.APIKey,
- APIBase: p.DeepSeek.APIBase,
- Proxy: p.DeepSeek.Proxy,
+ ModelName: "deepseek",
+ Model: "deepseek/deepseek-chat",
+ APIKey: p.DeepSeek.APIKey,
+ APIBase: p.DeepSeek.APIBase,
+ Proxy: p.DeepSeek.Proxy,
+ RequestTimeout: p.DeepSeek.RequestTimeout,
}, true
},
},
@@ -254,11 +266,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "cerebras",
- Model: "cerebras/llama-3.3-70b",
- APIKey: p.Cerebras.APIKey,
- APIBase: p.Cerebras.APIBase,
- Proxy: p.Cerebras.Proxy,
+ ModelName: "cerebras",
+ Model: "cerebras/llama-3.3-70b",
+ APIKey: p.Cerebras.APIKey,
+ APIBase: p.Cerebras.APIBase,
+ Proxy: p.Cerebras.Proxy,
+ RequestTimeout: p.Cerebras.RequestTimeout,
}, true
},
},
@@ -270,11 +283,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "volcengine",
- Model: "volcengine/doubao-pro",
- APIKey: p.VolcEngine.APIKey,
- APIBase: p.VolcEngine.APIBase,
- Proxy: p.VolcEngine.Proxy,
+ ModelName: "volcengine",
+ Model: "volcengine/doubao-pro",
+ APIKey: p.VolcEngine.APIKey,
+ APIBase: p.VolcEngine.APIBase,
+ Proxy: p.VolcEngine.Proxy,
+ RequestTimeout: p.VolcEngine.RequestTimeout,
}, true
},
},
@@ -316,11 +330,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "qwen",
- Model: "qwen/qwen-max",
- APIKey: p.Qwen.APIKey,
- APIBase: p.Qwen.APIBase,
- Proxy: p.Qwen.Proxy,
+ ModelName: "qwen",
+ Model: "qwen/qwen-max",
+ APIKey: p.Qwen.APIKey,
+ APIBase: p.Qwen.APIBase,
+ Proxy: p.Qwen.Proxy,
+ RequestTimeout: p.Qwen.RequestTimeout,
}, true
},
},
@@ -332,11 +347,12 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
return ModelConfig{}, false
}
return ModelConfig{
- ModelName: "mistral",
- Model: "mistral/mistral-small-latest",
- APIKey: p.Mistral.APIKey,
- APIBase: p.Mistral.APIBase,
- Proxy: p.Mistral.Proxy,
+ ModelName: "mistral",
+ Model: "mistral/mistral-small-latest",
+ APIKey: p.Mistral.APIKey,
+ APIBase: p.Mistral.APIBase,
+ Proxy: p.Mistral.Proxy,
+ RequestTimeout: p.Mistral.RequestTimeout,
}, true
},
},
diff --git a/pkg/config/migration_test.go b/pkg/config/migration_test.go
index 42165cb71..db8f4657d 100644
--- a/pkg/config/migration_test.go
+++ b/pkg/config/migration_test.go
@@ -166,6 +166,27 @@ func TestConvertProvidersToModelList_Proxy(t *testing.T) {
}
}
+func TestConvertProvidersToModelList_RequestTimeout(t *testing.T) {
+ cfg := &Config{
+ Providers: ProvidersConfig{
+ Ollama: ProviderConfig{
+ APIKey: "ollama-key",
+ RequestTimeout: 300,
+ },
+ },
+ }
+
+ result := ConvertProvidersToModelList(cfg)
+
+ if len(result) != 1 {
+ t.Fatalf("len(result) = %d, want 1", len(result))
+ }
+
+ if result[0].RequestTimeout != 300 {
+ t.Errorf("RequestTimeout = %d, want %d", result[0].RequestTimeout, 300)
+ }
+}
+
func TestConvertProvidersToModelList_AuthMethod(t *testing.T) {
cfg := &Config{
Providers: ProvidersConfig{
diff --git a/pkg/config/model_config_test.go b/pkg/config/model_config_test.go
index 99eea2782..084f50a82 100644
--- a/pkg/config/model_config_test.go
+++ b/pkg/config/model_config_test.go
@@ -365,3 +365,38 @@ func TestConfig_ValidateModelList(t *testing.T) {
})
}
}
+
+func TestModelConfig_RequestTimeoutParsing(t *testing.T) {
+ jsonData := `{
+ "model_name": "slow-local",
+ "model": "openai/local-model",
+ "api_base": "http://localhost:11434/v1",
+ "request_timeout": 300
+ }`
+
+ var cfg ModelConfig
+ if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if cfg.RequestTimeout != 300 {
+ t.Fatalf("RequestTimeout = %d, want 300", cfg.RequestTimeout)
+ }
+}
+
+func TestModelConfig_RequestTimeoutDefaultZeroValue(t *testing.T) {
+ jsonData := `{
+ "model_name": "default-timeout",
+ "model": "openai/gpt-4o",
+ "api_key": "test-key"
+ }`
+
+ var cfg ModelConfig
+ if err := json.Unmarshal([]byte(jsonData), &cfg); err != nil {
+ t.Fatalf("Unmarshal() error = %v", err)
+ }
+
+ if cfg.RequestTimeout != 0 {
+ t.Fatalf("RequestTimeout = %d, want 0", cfg.RequestTimeout)
+ }
+}
diff --git a/pkg/cron/service.go b/pkg/cron/service.go
index e699a44b5..6962041c1 100644
--- a/pkg/cron/service.go
+++ b/pkg/cron/service.go
@@ -7,11 +7,12 @@ import (
"fmt"
"log"
"os"
- "path/filepath"
"sync"
"time"
"github.com/adhocore/gronx"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
type CronSchedule struct {
@@ -330,17 +331,13 @@ func (cs *CronService) loadStore() 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, "", " ")
if err != nil {
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(
diff --git a/pkg/fileutil/file.go b/pkg/fileutil/file.go
new file mode 100644
index 000000000..7ca872374
--- /dev/null
+++ b/pkg/fileutil/file.go
@@ -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
+}
diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go
index e05a9fdbf..58462c120 100644
--- a/pkg/heartbeat/service.go
+++ b/pkg/heartbeat/service.go
@@ -16,6 +16,7 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
@@ -275,7 +276,7 @@ This file contains tasks for the heartbeat service to check periodically.
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)
} else {
hs.logInfof("Created default HEARTBEAT.md template")
diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go
index 7d5566eef..53f7a08a0 100644
--- a/pkg/providers/factory_provider.go
+++ b/pkg/providers/factory_provider.go
@@ -84,7 +84,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
- return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ ), modelID, nil
case "openrouter", "groq", "zhipu", "gemini", "nvidia",
"ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras",
@@ -97,7 +103,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if apiBase == "" {
apiBase = getDefaultAPIBase(protocol)
}
- return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ ), modelID, nil
case "anthropic":
if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" {
@@ -116,7 +128,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
if cfg.APIKey == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
}
- return NewHTTPProviderWithMaxTokensField(cfg.APIKey, apiBase, cfg.Proxy, cfg.MaxTokensField), modelID, nil
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ cfg.APIKey,
+ apiBase,
+ cfg.Proxy,
+ cfg.MaxTokensField,
+ cfg.RequestTimeout,
+ ), modelID, nil
case "antigravity":
return NewAntigravityProvider(), modelID, nil
diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go
index 6b133101a..e0c0eddef 100644
--- a/pkg/providers/factory_provider_test.go
+++ b/pkg/providers/factory_provider_test.go
@@ -6,7 +6,11 @@
package providers
import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/config"
)
@@ -247,3 +251,42 @@ func TestCreateProviderFromConfig_EmptyModel(t *testing.T) {
t.Fatal("CreateProviderFromConfig() expected error for empty model")
}
}
+
+func TestCreateProviderFromConfig_RequestTimeoutPropagation(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ time.Sleep(1500 * time.Millisecond)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}`))
+ }))
+ defer server.Close()
+
+ cfg := &config.ModelConfig{
+ ModelName: "test-timeout",
+ Model: "openai/gpt-4o",
+ APIBase: server.URL,
+ RequestTimeout: 1,
+ }
+
+ provider, modelID, err := CreateProviderFromConfig(cfg)
+ if err != nil {
+ t.Fatalf("CreateProviderFromConfig() error = %v", err)
+ }
+ if modelID != "gpt-4o" {
+ t.Fatalf("modelID = %q, want %q", modelID, "gpt-4o")
+ }
+
+ _, err = provider.Chat(
+ t.Context(),
+ []Message{{Role: "user", Content: "hi"}},
+ nil,
+ modelID,
+ nil,
+ )
+ if err == nil {
+ t.Fatal("Chat() expected timeout error, got nil")
+ }
+ errMsg := err.Error()
+ if !strings.Contains(errMsg, "context deadline exceeded") && !strings.Contains(errMsg, "Client.Timeout exceeded") {
+ t.Fatalf("Chat() error = %q, want timeout-related error", errMsg)
+ }
+}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index d0c4344f3..5c328f418 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -8,6 +8,7 @@ package providers
import (
"context"
+ "time"
"github.com/sipeed/picoclaw/pkg/providers/openai_compat"
)
@@ -23,8 +24,21 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
}
func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider {
+ return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0)
+}
+
+func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(
+ apiKey, apiBase, proxy, maxTokensField string,
+ requestTimeoutSeconds int,
+) *HTTPProvider {
return &HTTPProvider{
- delegate: openai_compat.NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField),
+ delegate: openai_compat.NewProvider(
+ apiKey,
+ apiBase,
+ proxy,
+ openai_compat.WithMaxTokensField(maxTokensField),
+ openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
+ ),
}
}
diff --git a/pkg/providers/legacy_provider.go b/pkg/providers/legacy_provider.go
index 23f137538..26905159f 100644
--- a/pkg/providers/legacy_provider.go
+++ b/pkg/providers/legacy_provider.go
@@ -18,9 +18,21 @@ import (
func CreateProvider(cfg *config.Config) (LLMProvider, string, error) {
model := cfg.Agents.Defaults.GetModelName()
- // Ensure model_list is populated (should be done by LoadConfig, but handle edge cases)
- if len(cfg.ModelList) == 0 && cfg.HasProvidersConfig() {
- cfg.ModelList = config.ConvertProvidersToModelList(cfg)
+ // Ensure model_list is populated from providers config if needed
+ // This handles two cases:
+ // 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
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index 087d3506e..7dace71f2 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -34,13 +34,27 @@ type Provider struct {
httpClient *http.Client
}
-func NewProvider(apiKey, apiBase, proxy string) *Provider {
- return NewProviderWithMaxTokensField(apiKey, apiBase, proxy, "")
+type Option func(*Provider)
+
+const defaultRequestTimeout = 120 * time.Second
+
+func WithMaxTokensField(maxTokensField string) Option {
+ return func(p *Provider) {
+ p.maxTokensField = maxTokensField
+ }
}
-func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
+func WithRequestTimeout(timeout time.Duration) Option {
+ return func(p *Provider) {
+ if timeout > 0 {
+ p.httpClient.Timeout = timeout
+ }
+ }
+}
+
+func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider {
client := &http.Client{
- Timeout: 120 * time.Second,
+ Timeout: defaultRequestTimeout,
}
if proxy != "" {
@@ -54,12 +68,36 @@ func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string
}
}
- return &Provider{
- apiKey: apiKey,
- apiBase: strings.TrimRight(apiBase, "/"),
- maxTokensField: maxTokensField,
- httpClient: client,
+ p := &Provider{
+ apiKey: apiKey,
+ apiBase: strings.TrimRight(apiBase, "/"),
+ httpClient: client,
}
+
+ for _, opt := range opts {
+ if opt != nil {
+ opt(p)
+ }
+ }
+
+ return p
+}
+
+func NewProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *Provider {
+ return NewProvider(apiKey, apiBase, proxy, WithMaxTokensField(maxTokensField))
+}
+
+func NewProviderWithMaxTokensFieldAndTimeout(
+ apiKey, apiBase, proxy, maxTokensField string,
+ requestTimeoutSeconds int,
+) *Provider {
+ return NewProvider(
+ apiKey,
+ apiBase,
+ proxy,
+ WithMaxTokensField(maxTokensField),
+ WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second),
+ )
}
func (p *Provider) Chat(
diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go
index 594a48213..7247fea3e 100644
--- a/pkg/providers/openai_compat/provider_test.go
+++ b/pkg/providers/openai_compat/provider_test.go
@@ -6,6 +6,7 @@ import (
"net/http/httptest"
"net/url"
"testing"
+ "time"
)
func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) {
@@ -325,3 +326,38 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) {
t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto")
}
}
+
+func TestProvider_RequestTimeoutDefault(t *testing.T) {
+ p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 0)
+ if p.httpClient.Timeout != defaultRequestTimeout {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
+ }
+}
+
+func TestProvider_RequestTimeoutOverride(t *testing.T) {
+ p := NewProviderWithMaxTokensFieldAndTimeout("key", "https://example.com/v1", "", "", 300)
+ if p.httpClient.Timeout != 300*time.Second {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second)
+ }
+}
+
+func TestProvider_FunctionalOptionMaxTokensField(t *testing.T) {
+ p := NewProvider("key", "https://example.com/v1", "", WithMaxTokensField("max_completion_tokens"))
+ if p.maxTokensField != "max_completion_tokens" {
+ t.Fatalf("maxTokensField = %q, want %q", p.maxTokensField, "max_completion_tokens")
+ }
+}
+
+func TestProvider_FunctionalOptionRequestTimeout(t *testing.T) {
+ p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(45*time.Second))
+ if p.httpClient.Timeout != 45*time.Second {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, 45*time.Second)
+ }
+}
+
+func TestProvider_FunctionalOptionRequestTimeoutNonPositive(t *testing.T) {
+ p := NewProvider("key", "https://example.com/v1", "", WithRequestTimeout(-1*time.Second))
+ if p.httpClient.Timeout != defaultRequestTimeout {
+ t.Fatalf("http timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout)
+ }
+}
diff --git a/pkg/skills/installer.go b/pkg/skills/installer.go
index 3210509df..20f6a49d9 100644
--- a/pkg/skills/installer.go
+++ b/pkg/skills/installer.go
@@ -9,6 +9,9 @@ import (
"os"
"path/filepath"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
type SkillInstaller struct {
@@ -44,7 +47,7 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
return fmt.Errorf("failed to create request: %w", err)
}
- resp, err := client.Do(req)
+ resp, err := utils.DoRequestWithRetry(client, req)
if err != nil {
return fmt.Errorf("failed to fetch skill: %w", err)
}
@@ -64,7 +67,9 @@ func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) er
}
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)
}
@@ -94,7 +99,7 @@ func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableS
return nil, fmt.Errorf("failed to create request: %w", err)
}
- resp, err := client.Do(req)
+ resp, err := utils.DoRequestWithRetry(client, req)
if err != nil {
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
}
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index 5749d8983..67d3e70e0 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -13,7 +13,11 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
-var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
+var (
+ namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
+ reFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
+ reStripFrontmatter = regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
+)
const (
MaxNameLength = 64
@@ -257,10 +261,7 @@ func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string {
func (sl *SkillsLoader) extractFrontmatter(content string) string {
// Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
- // (?s) enables DOTALL so . matches newlines;
- // ^--- at start, then ... --- at start of line, honoring all three line ending types
- re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---`)
- match := re.FindStringSubmatch(content)
+ match := reFrontmatter.FindStringSubmatch(content)
if len(match) > 1 {
return match[1]
}
@@ -268,12 +269,7 @@ func (sl *SkillsLoader) extractFrontmatter(content string) string {
}
func (sl *SkillsLoader) stripFrontmatter(content string) string {
- // Support \n (Unix), \r\n (Windows), and \r (classic Mac) line endings for frontmatter blocks
- // (?s) enables DOTALL so . matches newlines;
- // ^--- at start, then ... --- at start of line, honoring all three line ending types
- // Match zero or more trailing line endings after closing --- (handles both with and without blank lines)
- re := regexp.MustCompile(`(?s)^---(?:\r\n|\n|\r)(.*?)(?:\r\n|\n|\r)---(?:\r\n|\n|\r)*`)
- return re.ReplaceAllString(content, "")
+ return reStripFrontmatter.ReplaceAllString(content, "")
}
func escapeXML(s string) string {
diff --git a/pkg/state/state.go b/pkg/state/state.go
index 1a92f82ed..1663faa4c 100644
--- a/pkg/state/state.go
+++ b/pkg/state/state.go
@@ -8,6 +8,8 @@ import (
"path/filepath"
"sync"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
// 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.
// This ensures that the state file is never corrupted:
// 1. Write to a temp file
-// 2. Rename temp file to target (atomic on POSIX systems)
-// 3. If rename fails, cleanup the temp file
+// 2. Sync to disk (critical for SD cards/flash storage)
+// 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.
func (sm *Manager) saveAtomic() error {
- // Create temp file in the same directory as the target
- tempFile := sm.stateFile + ".tmp"
-
- // Marshal state to JSON
+ // Use unified atomic write utility with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
data, err := json.MarshalIndent(sm.state, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal state: %w", err)
}
- // Write to temp file
- 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
+ return fileutil.WriteFileAtomic(sm.stateFile, data, 0o600)
}
// load loads the state from disk.
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 76d6f91e9..5914d0ab2 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strings"
"time"
+
+ "github.com/sipeed/picoclaw/pkg/fileutil"
)
// 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 {
- dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0o755); err != nil {
- return fmt.Errorf("failed to create parent directories: %w", err)
- }
-
- // 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
+ // 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(path, data, 0o600)
}
// 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.
- // 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.
- tmpRelPath := fmt.Sprintf("%s.%d.tmp", relPath, time.Now().UnixNano())
+ // Use atomic write pattern with explicit sync for flash storage reliability.
+ // Using 0o600 (owner read/write only) for secure default permissions.
+ tmpRelPath := fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())
- if err := root.WriteFile(tmpRelPath, data, 0o644); err != nil {
- root.Remove(tmpRelPath) // Ensure cleanup of partial/empty temp file
- return fmt.Errorf("failed to write to temp file: %w", err)
+ tmpFile, err := root.OpenFile(tmpRelPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
+ if err != nil {
+ 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 {
root.Remove(tmpRelPath)
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
})
}
diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go
index 55c0b678d..71bfe730b 100644
--- a/pkg/tools/skills_install.go
+++ b/pkg/tools/skills_install.go
@@ -9,6 +9,7 @@ import (
"sync"
"time"
+ "github.com/sipeed/picoclaw/pkg/fileutil"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/utils"
@@ -197,5 +198,6 @@ func writeOriginMeta(targetDir, registryName, slug, version string) error {
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)
}
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 44df28215..8ba2a723a 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -17,6 +17,19 @@ const (
userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
+// Pre-compiled regexes for HTML text extraction
+var (
+ reScript = regexp.MustCompile(``)
- result := re.ReplaceAllLiteralString(htmlContent, "")
- re = regexp.MustCompile(`