Merge branch 'refactor/channel-system' into main

This commit is contained in:
美電球
2026-02-27 20:04:08 +08:00
committed by GitHub
46 changed files with 748 additions and 284 deletions
+3 -1
View File
@@ -39,7 +39,9 @@ builds:
dockers_v2:
- id: picoclaw
dockerfile: Dockerfile.goreleaser
dockerfile: docker/Dockerfile.goreleaser
extra_files:
- docker/entrypoint.sh
ids:
- picoclaw
images:
+19 -15
View File
@@ -164,39 +164,43 @@ Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installe
git clone https://github.com/sipeed/picoclaw.git
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
+19 -15
View File
@@ -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
```
### 🚀 クイックスタート(ネイティブ)
+19 -15
View File
@@ -172,39 +172,43 @@ You can also run PicoClaw using Docker Compose without installing anything local
git clone https://github.com/sipeed/picoclaw.git
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
+19 -15
View File
@@ -165,39 +165,43 @@ Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada loca
git clone https://github.com/sipeed/picoclaw.git
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
+19 -15
View File
@@ -145,39 +145,43 @@ Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cà
git clone https://github.com/sipeed/picoclaw.git
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
+20 -18
View File
@@ -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
```
### 🚀 快速开始
Binary file not shown.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 366 KiB

+28 -12
View File
@@ -52,32 +52,41 @@
"proxy": "",
"allow_from": [
"YOUR_USER_ID"
]
],
"reasoning_channel_id": ""
},
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
"allow_from": [],
"mention_only": false
"mention_only": false,
"reasoning_channel_id": ""
},
"qq": {
"enabled": false,
"app_id": "YOUR_QQ_APP_ID",
"app_secret": "YOUR_QQ_APP_SECRET",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"maixcam": {
"enabled": false,
"host": "0.0.0.0",
"port": 18790,
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"whatsapp": {
"enabled": false,
"bridge_url": "ws://localhost:3001",
<<<<<<< main
"use_native": false,
"session_store_path": "",
"allow_from": []
=======
"allow_from": [],
"reasoning_channel_id": ""
>>>>>>> refactor/channel-system
},
"feishu": {
"enabled": false,
@@ -85,19 +94,22 @@
"app_secret": "",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"dingtalk": {
"enabled": false,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"slack": {
"enabled": false,
"bot_token": "xoxb-YOUR-BOT-TOKEN",
"app_token": "xapp-YOUR-APP-TOKEN",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"line": {
"enabled": false,
@@ -106,7 +118,8 @@
"webhook_host": "0.0.0.0",
"webhook_port": 18791,
"webhook_path": "/webhook/line",
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"onebot": {
"enabled": false,
@@ -114,7 +127,8 @@
"access_token": "",
"reconnect_interval": 5,
"group_trigger_prefix": [],
"allow_from": []
"allow_from": [],
"reasoning_channel_id": ""
},
"wecom": {
"_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
@@ -126,7 +140,8 @@
"webhook_port": 18793,
"webhook_path": "/webhook/wecom",
"allow_from": [],
"reply_timeout": 5
"reply_timeout": 5,
"reasoning_channel_id": ""
},
"wecom_app": {
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
@@ -140,7 +155,8 @@
"webhook_port": 18792,
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5
"reply_timeout": 5,
"reasoning_channel_id": ""
}
},
"providers": {
@@ -253,4 +269,4 @@
"host": "127.0.0.1",
"port": 18790
}
}
}
View File
@@ -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"]
@@ -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
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
set -e
# First-run: neither config nor workspace exists.
# If config.json is already mounted but workspace is missing we skip onboard to
# avoid the interactive "Overwrite? (y/n)" prompt hanging in a non-TTY container.
if [ ! -d "${HOME}/.picoclaw/workspace" ] && [ ! -f "${HOME}/.picoclaw/config.json" ]; then
picoclaw onboard
echo ""
echo "First-run setup complete."
echo "Edit ${HOME}/.picoclaw/config.json (add your API key, etc.) then restart the container."
exit 0
fi
exec picoclaw gateway "$@"
+44 -2
View File
@@ -55,6 +55,8 @@ type processOptions struct {
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 {
registry := NewAgentRegistry(cfg, provider)
@@ -330,7 +332,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
@@ -406,7 +408,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
DefaultResponse: "I've completed processing but have no response to give.",
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
})
@@ -551,6 +553,34 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
return finalContent, nil
}
func (al *AgentLoop) targetReasoningChannelID(channelName string) (chatID string) {
if al.channelManager == nil {
return ""
}
if ch, ok := al.channelManager.GetChannel(channelName); ok {
return ch.ReasoningChannelID()
}
return ""
}
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) {
if reasoningContent == "" || channelName == "" || channelID == "" {
return
}
// Check context cancellation before attempting to publish,
// since PublishOutbound's select may race between send and ctx.Done().
if ctx.Err() != nil {
return
}
al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: channelName,
ChatID: channelID,
Content: reasoningContent,
})
}
// runLLMIteration executes the LLM call loop with tool handling.
func (al *AgentLoop) runLLMIteration(
ctx context.Context,
@@ -677,6 +707,18 @@ func (al *AgentLoop) runLLMIteration(
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
}
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
logger.DebugCF("agent", "LLM response",
map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(response.Content),
"tool_calls": len(response.ToolCalls),
"reasoning": response.Reasoning,
"target_channel": al.targetReasoningChannelID(opts.Channel),
"channel": opts.Channel,
})
// Check if no tool calls - we're done
if len(response.ToolCalls) == 0 {
finalContent = response.Content
+167
View File
@@ -9,11 +9,23 @@ import (
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
)
type fakeChannel struct{ id string }
func (f *fakeChannel) Name() string { return "fake" }
func (f *fakeChannel) Start(ctx context.Context) error { return nil }
func (f *fakeChannel) Stop(ctx context.Context) error { return nil }
func (f *fakeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil }
func (f *fakeChannel) IsRunning() bool { return true }
func (f *fakeChannel) IsAllowed(string) bool { return true }
func (f *fakeChannel) IsAllowedSender(sender bus.SenderInfo) bool { return true }
func (f *fakeChannel) ReasoningChannelID() string { return f.id }
func TestRecordLastChannel(t *testing.T) {
// Create temp workspace
tmpDir, err := os.MkdirTemp("", "agent-test-*")
@@ -631,3 +643,158 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
}
}
func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
al := NewAgentLoop(cfg, bus.NewMessageBus(), &mockProvider{})
chManager, err := channels.NewManager(&config.Config{}, bus.NewMessageBus(), nil)
if err != nil {
t.Fatalf("Failed to create channel manager: %v", err)
}
for name, id := range map[string]string{
"whatsapp": "rid-whatsapp",
"telegram": "rid-telegram",
"feishu": "rid-feishu",
"discord": "rid-discord",
"maixcam": "rid-maixcam",
"qq": "rid-qq",
"dingtalk": "rid-dingtalk",
"slack": "rid-slack",
"line": "rid-line",
"onebot": "rid-onebot",
"wecom": "rid-wecom",
"wecom_app": "rid-wecom-app",
} {
chManager.RegisterChannel(name, &fakeChannel{id: id})
}
al.SetChannelManager(chManager)
tests := []struct {
channel string
wantID string
}{
{channel: "whatsapp", wantID: "rid-whatsapp"},
{channel: "telegram", wantID: "rid-telegram"},
{channel: "feishu", wantID: "rid-feishu"},
{channel: "discord", wantID: "rid-discord"},
{channel: "maixcam", wantID: "rid-maixcam"},
{channel: "qq", wantID: "rid-qq"},
{channel: "dingtalk", wantID: "rid-dingtalk"},
{channel: "slack", wantID: "rid-slack"},
{channel: "line", wantID: "rid-line"},
{channel: "onebot", wantID: "rid-onebot"},
{channel: "wecom", wantID: "rid-wecom"},
{channel: "wecom_app", wantID: "rid-wecom-app"},
{channel: "unknown", wantID: ""},
}
for _, tt := range tests {
t.Run(tt.channel, func(t *testing.T) {
got := al.targetReasoningChannelID(tt.channel)
if got != tt.wantID {
t.Fatalf("targetReasoningChannelID(%q) = %q, want %q", tt.channel, got, tt.wantID)
}
})
}
}
func TestHandleReasoning(t *testing.T) {
newLoop := func(t *testing.T) (*AgentLoop, *bus.MessageBus) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}
msgBus := bus.NewMessageBus()
return NewAgentLoop(cfg, msgBus, &mockProvider{}), msgBus
}
t.Run("skips when any required field is empty", func(t *testing.T) {
al, msgBus := newLoop(t)
al.handleReasoning(context.Background(), "reasoning", "telegram", "")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if msg, ok := msgBus.SubscribeOutbound(ctx); ok {
t.Fatalf("expected no outbound message, got %+v", msg)
}
})
t.Run("publishes one message for non telegram", func(t *testing.T) {
al, msgBus := newLoop(t)
al.handleReasoning(context.Background(), "hello reasoning", "slack", "channel-1")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
msg, ok := msgBus.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected an outbound message")
}
if msg.Channel != "slack" || msg.ChatID != "channel-1" || msg.Content != "hello reasoning" {
t.Fatalf("unexpected outbound message: %+v", msg)
}
})
t.Run("publishes one message for telegram", func(t *testing.T) {
al, msgBus := newLoop(t)
reasoning := "hello telegram reasoning"
al.handleReasoning(context.Background(), reasoning, "telegram", "tg-chat")
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
msg, ok := msgBus.SubscribeOutbound(ctx)
if !ok {
t.Fatal("expected outbound message")
}
if msg.Channel != "telegram" {
t.Fatalf("expected telegram channel message, got %+v", msg)
}
if msg.ChatID != "tg-chat" {
t.Fatalf("expected chatID tg-chat, got %+v", msg)
}
if msg.Content != reasoning {
t.Fatalf("content mismatch: got %q want %q", msg.Content, reasoning)
}
})
t.Run("expired ctx", func(t *testing.T) {
al, msgBus := newLoop(t)
reasoning := "hello telegram reasoning"
ctx, cancel := context.WithCancel(context.Background())
cancel()
al.handleReasoning(ctx, reasoning, "telegram", "tg-chat")
ctx, cancel = context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
msg, ok := msgBus.SubscribeOutbound(ctx)
if ok {
t.Fatalf("expected no outbound message, got %+v", msg)
}
})
}
+10 -3
View File
@@ -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.
+5 -6
View File
@@ -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) {
+9
View File
@@ -34,6 +34,9 @@ func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) er
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.inbound <- msg:
return nil
@@ -59,6 +62,9 @@ func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage)
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.outbound <- msg:
return nil
@@ -84,6 +90,9 @@ func (mb *MessageBus) PublishOutboundMedia(ctx context.Context, msg OutboundMedi
if mb.closed.Load() {
return ErrBusClosed
}
if err := ctx.Err(); err != nil {
return err
}
select {
case mb.outboundMedia <- msg:
return nil
+11
View File
@@ -48,6 +48,7 @@ type Channel interface {
IsRunning() bool
IsAllowed(senderID string) bool
IsAllowedSender(sender bus.SenderInfo) bool
ReasoningChannelID() string
}
// BaseChannelOption is a functional option for configuring a BaseChannel.
@@ -65,6 +66,11 @@ func WithGroupTrigger(gt config.GroupTriggerConfig) BaseChannelOption {
return func(c *BaseChannel) { c.groupTrigger = gt }
}
// WithReasoningChannelID sets the reasoning channel ID where thoughts should be sent.
func WithReasoningChannelID(id string) BaseChannelOption {
return func(c *BaseChannel) { c.reasoningChannelID = id }
}
// MessageLengthProvider is an opt-in interface that channels implement
// to advertise their maximum message length. The Manager uses this via
// type assertion to decide whether to split outbound messages.
@@ -83,6 +89,7 @@ type BaseChannel struct {
mediaStore media.MediaStore
placeholderRecorder PlaceholderRecorder
owner Channel // the concrete channel that embeds this BaseChannel
reasoningChannelID string
}
func NewBaseChannel(
@@ -154,6 +161,10 @@ func (c *BaseChannel) Name() string {
return c.name
}
func (c *BaseChannel) ReasoningChannelID() string {
return c.reasoningChannelID
}
func (c *BaseChannel) IsRunning() bool {
return c.running.Load()
}
+1
View File
@@ -42,6 +42,7 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &DingTalkChannel{
+1
View File
@@ -43,6 +43,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
base := channels.NewBaseChannel("discord", cfg, bus, cfg.AllowFrom,
channels.WithMaxMessageLength(2000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &DiscordChannel{
+1
View File
@@ -35,6 +35,7 @@ type FeishuChannel struct {
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := channels.NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &FeishuChannel{
+1
View File
@@ -63,6 +63,7 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
base := channels.NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(5000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &LINEChannel{
+7 -1
View File
@@ -33,7 +33,13 @@ type MaixCamMessage struct {
}
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
base := channels.NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom)
base := channels.NewBaseChannel(
"maixcam",
cfg,
bus,
cfg.AllowFrom,
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &MaixCamChannel{
BaseChannel: base,
+1
View File
@@ -99,6 +99,7 @@ type oneBotMessageSegment struct {
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
base := channels.NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
const dedupSize = 1024
+1
View File
@@ -35,6 +35,7 @@ type QQChannel struct {
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &QQChannel{
+1
View File
@@ -51,6 +51,7 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack
base := channels.NewBaseChannel("slack", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(40000),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &SlackChannel{
+1
View File
@@ -84,6 +84,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
telegramCfg.AllowFrom,
channels.WithMaxMessageLength(4096),
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
)
return &TelegramChannel{
+1
View File
@@ -126,6 +126,7 @@ func NewWeComAppChannel(cfg config.WeComAppConfig, messageBus *bus.MessageBus) (
base := channels.NewBaseChannel("wecom_app", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &WeComAppChannel{
+1
View File
@@ -90,6 +90,7 @@ func NewWeComBotChannel(cfg config.WeComConfig, messageBus *bus.MessageBus) (*We
base := channels.NewBaseChannel("wecom", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(2048),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &WeComBotChannel{
+8 -1
View File
@@ -29,7 +29,14 @@ type WhatsAppChannel struct {
}
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
base := channels.NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom, channels.WithMaxMessageLength(65536))
base := channels.NewBaseChannel(
"whatsapp",
cfg,
bus,
cfg.AllowFrom,
channels.WithMaxMessageLength(65536),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &WhatsAppChannel{
BaseChannel: base,
+82 -70
View File
@@ -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.
@@ -228,68 +229,77 @@ type WhatsAppConfig struct {
UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"`
SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
}
type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_FEISHU_REASONING_CHANNEL_ID"`
}
type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
MentionOnly bool `json:"mention_only" env:"PICOCLAW_CHANNELS_DISCORD_MENTION_ONLY"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DISCORD_REASONING_CHANNEL_ID"`
}
type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MAIXCAM_REASONING_CHANNEL_ID"`
}
type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
}
type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_DINGTALK_REASONING_CHANNEL_ID"`
}
type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_SLACK_REASONING_CHANNEL_ID"`
}
type LINEConfig struct {
@@ -302,6 +312,8 @@ type LINEConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_LINE_REASONING_CHANNEL_ID"`
}
type OneBotConfig struct {
@@ -313,34 +325,38 @@ type OneBotConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_ONEBOT_REASONING_CHANNEL_ID"`
}
type WeComConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_ENCODING_AES_KEY"`
WebhookURL string `json:"webhook_url" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_URL"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_REASONING_CHANNEL_ID"`
}
type WeComAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WECOM_APP_ENABLED"`
CorpID string `json:"corp_id" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_ID"`
CorpSecret string `json:"corp_secret" env:"PICOCLAW_CHANNELS_WECOM_APP_CORP_SECRET"`
AgentID int64 `json:"agent_id" env:"PICOCLAW_CHANNELS_WECOM_APP_AGENT_ID"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_WECOM_APP_TOKEN"`
EncodingAESKey string `json:"encoding_aes_key" env:"PICOCLAW_CHANNELS_WECOM_APP_ENCODING_AES_KEY"`
WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_HOST"`
WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PORT"`
WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_WECOM_APP_WEBHOOK_PATH"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WECOM_APP_ALLOW_FROM"`
ReplyTimeout int `json:"reply_timeout" env:"PICOCLAW_CHANNELS_WECOM_APP_REPLY_TIMEOUT"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WECOM_APP_REASONING_CHANNEL_ID"`
}
type PicoConfig struct {
@@ -627,12 +643,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 {
+4 -4
View File
@@ -210,8 +210,8 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) {
func TestDefaultConfig_Model(t *testing.T) {
cfg := DefaultConfig()
if cfg.Agents.Defaults.Model == "" {
t.Error("Model should not be empty")
if cfg.Agents.Defaults.Model != "" {
t.Error("Model should be empty")
}
}
@@ -331,8 +331,8 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.Workspace == "" {
t.Error("Workspace should not be empty")
}
if cfg.Agents.Defaults.Model == "" {
t.Error("Model should not be empty")
if cfg.Agents.Defaults.Model != "" {
t.Error("Model should be empty")
}
if cfg.Agents.Defaults.Temperature != nil {
t.Error("Temperature should be nil when not provided")
+3 -3
View File
@@ -13,10 +13,10 @@ func DefaultConfig() *Config {
Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true,
Provider: "",
Model: "glm-4.7",
MaxTokens: 8192,
Model: "",
MaxTokens: 32768,
Temperature: nil, // nil means use provider default
MaxToolIterations: 20,
MaxToolIterations: 50,
},
},
Bindings: []AgentBinding{},
+4 -7
View File
@@ -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(
+119
View File
@@ -0,0 +1,119 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
// Package fileutil provides file manipulation utilities.
package fileutil
import (
"fmt"
"os"
"path/filepath"
"time"
)
// WriteFileAtomic atomically writes data to a file using a temp file + rename pattern.
//
// This guarantees that the target file is either:
// - Completely written with the new data
// - Unchanged (if any step fails before rename)
//
// The function:
// 1. Creates a temp file in the same directory (original untouched)
// 2. Writes data to temp file
// 3. Syncs data to disk (critical for SD cards/flash storage)
// 4. Sets file permissions
// 5. Syncs directory metadata (ensures rename is durable)
// 6. Atomically renames temp file to target path
//
// Safety guarantees:
// - Original file is NEVER modified until successful rename
// - Temp file is always cleaned up on error
// - Data is flushed to physical storage before rename
// - Directory entry is synced to prevent orphaned inodes
//
// Parameters:
// - path: Target file path
// - data: Data to write
// - perm: File permission mode (e.g., 0o600 for secure, 0o644 for readable)
//
// Returns:
// - Error if any step fails, nil on success
//
// Example:
//
// // Secure config file (owner read/write only)
// err := utils.WriteFileAtomic("config.json", data, 0o600)
//
// // Public readable file
// err := utils.WriteFileAtomic("public.txt", data, 0o644)
func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
// Create temp file in the same directory (ensures atomic rename works)
// Using a hidden prefix (.tmp-) to avoid issues with some tools
tmpFile, err := os.OpenFile(
filepath.Join(dir, fmt.Sprintf(".tmp-%d-%d", os.Getpid(), time.Now().UnixNano())),
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
perm,
)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
tmpPath := tmpFile.Name()
cleanup := true
defer func() {
if cleanup {
tmpFile.Close()
_ = os.Remove(tmpPath)
}
}()
// Write data to temp file
// Note: Original file is untouched at this point
if _, err := tmpFile.Write(data); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
// CRITICAL: Force sync to storage medium before any other operations.
// This ensures data is physically written to disk, not just cached.
// Essential for SD cards, eMMC, and other flash storage on edge devices.
if err := tmpFile.Sync(); err != nil {
return fmt.Errorf("failed to sync temp file: %w", err)
}
// Set file permissions before closing
if err := tmpFile.Chmod(perm); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
}
// Close file before rename (required on Windows)
if err := tmpFile.Close(); err != nil {
return fmt.Errorf("failed to close temp file: %w", err)
}
// Atomic rename: temp file becomes the target
// On POSIX: rename() is atomic
// On Windows: Rename() is atomic for files
if err := os.Rename(tmpPath, path); err != nil {
return fmt.Errorf("failed to rename temp file: %w", err)
}
// Sync directory to ensure rename is durable
// This prevents the renamed file from disappearing after a crash
if dirFile, err := os.Open(dir); err == nil {
_ = dirFile.Sync()
dirFile.Close()
}
// Success: skip cleanup (file was renamed, no temp to remove)
cleanup = false
return nil
}
+2 -1
View File
@@ -17,6 +17,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"
@@ -276,7 +277,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")
+2 -2
View File
@@ -296,8 +296,8 @@ func TestConvertConfig(t *testing.T) {
if len(warnings) != 0 {
t.Errorf("expected no warnings, got %v", warnings)
}
if cfg.Agents.Defaults.Model != "glm-4.7" {
t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model)
if cfg.Agents.Defaults.Model != "" {
t.Errorf("default model should be nil, got %q", cfg.Agents.Defaults.Model)
}
})
}
+15 -3
View File
@@ -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
+7 -2
View File
@@ -25,6 +25,7 @@ type (
ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition
ExtraContent = protocoltypes.ExtraContent
GoogleExtra = protocoltypes.GoogleExtra
ReasoningDetail = protocoltypes.ReasoningDetail
)
type Provider struct {
@@ -198,8 +199,10 @@ func parseResponse(body []byte) (*LLMResponse, error) {
var apiResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
@@ -274,6 +277,8 @@ func parseResponse(body []byte) (*LLMResponse, error) {
return &LLMResponse{
Content: choice.Message.Content,
ReasoningContent: choice.Message.ReasoningContent,
Reasoning: choice.Message.Reasoning,
ReasoningDetails: choice.Message.ReasoningDetails,
ToolCalls: toolCalls,
FinishReason: choice.FinishReason,
Usage: apiResponse.Usage,
+14 -5
View File
@@ -25,11 +25,20 @@ type FunctionCall struct {
}
type LLMResponse struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason"`
Usage *UsageInfo `json:"usage,omitempty"`
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason"`
Usage *UsageInfo `json:"usage,omitempty"`
Reasoning string `json:"reasoning"`
ReasoningDetails []ReasoningDetail `json:"reasoning_details"`
}
type ReasoningDetail struct {
Format string `json:"format"`
Index int `json:"index"`
Type string `json:"type"`
Text string `json:"text"`
}
type UsageInfo struct {
+4 -1
View File
@@ -10,6 +10,7 @@ import (
"path/filepath"
"time"
"github.com/sipeed/picoclaw/pkg/fileutil"
"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")
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)
}
+8 -19
View File
@@ -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.
+38 -26
View File
@@ -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
})
}
+3 -1
View File
@@ -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)
}