mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Merge branch 'sipeed:main' into reasoning-chnl
This commit is contained in:
+3
-1
@@ -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:
|
||||||
|
|||||||
+19
-15
@@ -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
|
||||||
|
|||||||
+19
-15
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
### 🚀 クイックスタート(ネイティブ)
|
### 🚀 クイックスタート(ネイティブ)
|
||||||
|
|||||||
@@ -167,39 +167,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
|
||||||
|
|||||||
+19
-15
@@ -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
|
||||||
|
|||||||
+19
-15
@@ -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
|
||||||
|
|||||||
+20
-18
@@ -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 |
@@ -5,6 +5,8 @@ ARG TARGETPLATFORM
|
|||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["picoclaw"]
|
RUN chmod +x /entrypoint.sh
|
||||||
CMD ["gateway"]
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
services:
|
services:
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Agent (one-shot query)
|
# PicoClaw Agent (one-shot query)
|
||||||
# docker compose run --rm picoclaw-agent -m "Hello"
|
# docker compose -f docker/docker-compose.yml run --rm picoclaw-agent -m "Hello"
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-agent:
|
picoclaw-agent:
|
||||||
build:
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-agent
|
container_name: picoclaw-agent
|
||||||
profiles:
|
profiles:
|
||||||
- agent
|
- agent
|
||||||
@@ -14,33 +12,23 @@ services:
|
|||||||
#extra_hosts:
|
#extra_hosts:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
- ./data:/root/.picoclaw
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
|
||||||
entrypoint: ["picoclaw", "agent"]
|
entrypoint: ["picoclaw", "agent"]
|
||||||
stdin_open: true
|
stdin_open: true
|
||||||
tty: true
|
tty: true
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# PicoClaw Gateway (Long-running Bot)
|
# PicoClaw Gateway (Long-running Bot)
|
||||||
# docker compose up picoclaw-gateway
|
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
picoclaw-gateway:
|
picoclaw-gateway:
|
||||||
build:
|
image: docker.io/sipeed/picoclaw:latest
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: picoclaw-gateway
|
container_name: picoclaw-gateway
|
||||||
restart: unless-stopped
|
restart: on-failure
|
||||||
profiles:
|
profiles:
|
||||||
- gateway
|
- gateway
|
||||||
# Uncomment to access host network; leave commented unless needed.
|
# Uncomment to access host network; leave commented unless needed.
|
||||||
#extra_hosts:
|
#extra_hosts:
|
||||||
# - "host.docker.internal:host-gateway"
|
# - "host.docker.internal:host-gateway"
|
||||||
volumes:
|
volumes:
|
||||||
# Configuration file
|
- ./data:/root/.picoclaw
|
||||||
- ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
|
|
||||||
# Persistent workspace (sessions, memory, logs)
|
|
||||||
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
|
|
||||||
command: ["gateway"]
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
picoclaw-workspace:
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# First-run: neither config nor workspace exists.
|
||||||
|
# If config.json is already mounted but workspace is missing we skip onboard to
|
||||||
|
# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container.
|
||||||
|
if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then
|
||||||
|
picoclaw onboard
|
||||||
|
echo ""
|
||||||
|
echo "First-run setup complete."
|
||||||
|
echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec picoclaw gateway "$@"
|
||||||
+4
-2
@@ -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)
|
||||||
|
|
||||||
@@ -315,7 +317,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
|
||||||
@@ -381,7 +383,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,
|
||||||
})
|
})
|
||||||
|
|||||||
+10
-3
@@ -12,6 +12,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MemoryStore manages persistent memory for the agent.
|
// MemoryStore manages persistent memory for the agent.
|
||||||
@@ -58,7 +60,9 @@ func (ms *MemoryStore) ReadLongTerm() string {
|
|||||||
|
|
||||||
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
|
||||||
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
func (ms *MemoryStore) WriteLongTerm(content string) error {
|
||||||
return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
// Using 0o600 (owner read/write only) for secure default permissions.
|
||||||
|
return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReadToday reads today's daily note.
|
// ReadToday reads today's daily note.
|
||||||
@@ -78,7 +82,9 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
|||||||
|
|
||||||
// Ensure month directory exists
|
// Ensure month directory exists
|
||||||
monthDir := filepath.Dir(todayFile)
|
monthDir := filepath.Dir(todayFile)
|
||||||
os.MkdirAll(monthDir, 0o755)
|
if err := os.MkdirAll(monthDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var existingContent string
|
var existingContent string
|
||||||
if data, err := os.ReadFile(todayFile); err == nil {
|
if data, err := os.ReadFile(todayFile); err == nil {
|
||||||
@@ -95,7 +101,8 @@ func (ms *MemoryStore) AppendToday(content string) error {
|
|||||||
newContent = existingContent + "\n" + content
|
newContent = existingContent + "\n" + content
|
||||||
}
|
}
|
||||||
|
|
||||||
return os.WriteFile(todayFile, []byte(newContent), 0o644)
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(todayFile, []byte(newContent), 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRecentDailyNotes returns daily notes from the last N days.
|
// GetRecentDailyNotes returns daily notes from the last N days.
|
||||||
|
|||||||
+5
-6
@@ -5,6 +5,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/fileutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuthCredential struct {
|
type AuthCredential struct {
|
||||||
@@ -63,16 +65,13 @@ func LoadStore() (*AuthStore, error) {
|
|||||||
|
|
||||||
func SaveStore(store *AuthStore) error {
|
func SaveStore(store *AuthStore) error {
|
||||||
path := authFilePath()
|
path := authFilePath()
|
||||||
dir := filepath.Dir(path)
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(store, "", " ")
|
data, err := json.MarshalIndent(store, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0o600)
|
|
||||||
|
// Use unified atomic write utility with explicit sync for flash storage reliability.
|
||||||
|
return fileutil.WriteFileAtomic(path, data, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetCredential(provider string) (*AuthCredential, error) {
|
func GetCredential(provider string) (*AuthCredential, error) {
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -640,12 +641,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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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{},
|
||||||
|
|||||||
+4
-7
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-19
@@ -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.
|
||||||
|
|||||||
+38
-26
@@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user