diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index aad0f32..465d1d6 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,7 +3,6 @@ name: build on: push: branches: ["main"] - pull_request: jobs: build: diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..35ad87a --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,52 @@ +name: pr-check + +on: + pull_request: + +jobs: + fmt-check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Check formatting + run: | + make fmt + git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1) + + vet: + runs-on: ubuntu-latest + needs: fmt-check + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run go vet + run: go vet ./... + + test: + runs-on: ubuntu-latest + needs: fmt-check + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run go test + run: go test ./... + diff --git a/.gitignore b/.gitignore index 7163f5f..6ba4117 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ coverage.html # Ralph workspace ralph/ -.ralph/ \ No newline at end of file +.ralph/ +tasks/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5168e7b..8db9955 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ # ============================================================ # Stage 1: Build the picoclaw binary # ============================================================ -FROM golang:1.25-alpine AS builder +FROM golang:1.25.7-alpine AS builder RUN apk add --no-cache git make diff --git a/Makefile b/Makefile index c9af7d5..2defcce 100644 --- a/Makefile +++ b/Makefile @@ -8,9 +8,10 @@ MAIN_GO=$(CMD_DIR)/main.go # Version VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") +GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') -LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)" +LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)" # Go variables GO?=go diff --git a/README.ja.md b/README.ja.md index 311ce30..48105ce 100644 --- a/README.ja.md +++ b/README.ja.md @@ -186,7 +186,7 @@ picoclaw onboard "providers": { "openrouter": { "api_key": "xxx", - "api_base": "https://open.bigmodel.cn/api/paas/v4" + "api_base": "https://openrouter.ai/api/v1" } }, "tools": { @@ -223,12 +223,14 @@ picoclaw agent -m "What is 2+2?" ## 💬 チャットアプリ -Telegram で PicoClaw と会話できます +Telegram、Discord、QQ、DingTalk で PicoClaw と会話できます | チャネル | セットアップ | |---------|------------| | **Telegram** | 簡単(トークンのみ) | | **Discord** | 簡単(Bot トークン + Intents) | +| **QQ** | 簡単(AppID + AppSecret) | +| **DingTalk** | 普通(アプリ認証情報) |
Telegram(推奨) @@ -307,6 +309,73 @@ picoclaw gateway
+
+QQ + +**1. Bot を作成** + +- [QQ オープンプラットフォーム](https://connect.qq.com/) にアクセス +- アプリケーションを作成 → **AppID** と **AppSecret** を取得 + +**2. 設定** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` を空にすると全ユーザーを許可、QQ番号を指定してアクセス制限可能。 + +**3. 起動** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Bot を作成** + +- [オープンプラットフォーム](https://open.dingtalk.com/) にアクセス +- 内部アプリを作成 +- Client ID と Client Secret をコピー + +**2. 設定** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> `allow_from` を空にすると全ユーザーを許可、ユーザーIDを指定してアクセス制限可能。 + +**3. 起動** + +```bash +picoclaw gateway +``` + +
+ ## ⚙️ 設定 設定ファイル: `~/.picoclaw/config.json` @@ -330,6 +399,98 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw └── USER.md # ユーザー設定 ``` +### 🔒 セキュリティサンドボックス + +PicoClaw はデフォルトでサンドボックス環境で実行されます。エージェントは設定されたワークスペース内のファイルにのみアクセスし、コマンドを実行できます。 + +#### デフォルト設定 + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| オプション | デフォルト | 説明 | +|-----------|-----------|------| +| `workspace` | `~/.picoclaw/workspace` | エージェントの作業ディレクトリ | +| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペースに制限 | + +#### 保護対象ツール + +`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます: + +| ツール | 機能 | 制限 | +|-------|------|------| +| `read_file` | ファイル読み込み | ワークスペース内のファイルのみ | +| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ | +| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ | +| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ | +| `append_file` | ファイル追記 | ワークスペース内のファイルのみ | +| `exec` | コマンド実行 | コマンドパスはワークスペース内である必要あり | + +#### exec ツールの追加保護 + +`restrict_to_workspace: false` でも、`exec` ツールは以下の危険なコマンドをブロックします: + +- `rm -rf`, `del /f`, `rmdir /s` — 一括削除 +- `format`, `mkfs`, `diskpart` — ディスクフォーマット +- `dd if=` — ディスクイメージング +- `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み +- `shutdown`, `reboot`, `poweroff` — システムシャットダウン +- フォークボム `:(){ :|:& };:` + +#### エラー例 + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### 制限の無効化(セキュリティリスク) + +エージェントにワークスペース外のパスへのアクセスが必要な場合: + +**方法1: 設定ファイル** +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**方法2: 環境変数** +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **警告**: この制限を無効にすると、エージェントはシステム上の任意のパスにアクセスできるようになります。制御された環境でのみ慎重に使用してください。 + +#### セキュリティ境界の一貫性 + +`restrict_to_workspace` 設定は、すべての実行パスで一貫して適用されます: + +| 実行パス | セキュリティ境界 | +|---------|-----------------| +| メインエージェント | `restrict_to_workspace` ✅ | +| サブエージェント / Spawn | 同じ制限を継承 ✅ | +| ハートビートタスク | 同じ制限を継承 ✅ | + +すべてのパスで同じワークスペース制限が適用されます — サブエージェントやスケジュールタスクを通じてセキュリティ境界をバイパスする方法はありません。 + ### ハートビート(定期タスク) PicoClaw は自動的に定期タスクを実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成します: diff --git a/README.md b/README.md index 4921957..536444b 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,30 @@
-PicoClaw + PicoClaw -

PicoClaw: Ultra-Efficient AI Assistant in Go

+

PicoClaw: Ultra-Efficient AI Assistant in Go

-

$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!

-

+

$10 Hardware · 10MB RAM · 1s Boot · 皮皮虾,我们走!

-

-Go -Hardware -License -

- -[日本語](README.ja.md) | **English** +

+ Go + Hardware + License +
+ Website + Twitter +

+ [中文](README.zh.md) | [日本語](README.ja.md) | **English**
+ --- 🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [nanobot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization. ⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini! +
@@ -37,7 +40,19 @@
+ +> [!CAUTION] +> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** +> +> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**. +> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)** +> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties. +> + ## 📢 News +2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. +🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. + 2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go! @@ -413,6 +428,98 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa └── USER.md # User preferences ``` +### 🔒 Security Sandbox + +PicoClaw runs in a sandboxed environment by default. The agent can only access files and execute commands within the configured workspace. + +#### Default Configuration + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `workspace` | `~/.picoclaw/workspace` | Working directory for the agent | +| `restrict_to_workspace` | `true` | Restrict file/command access to workspace | + +#### Protected Tools + +When `restrict_to_workspace: true`, the following tools are sandboxed: + +| Tool | Function | Restriction | +|------|----------|-------------| +| `read_file` | Read files | Only files within workspace | +| `write_file` | Write files | Only files within workspace | +| `list_dir` | List directories | Only directories within workspace | +| `edit_file` | Edit files | Only files within workspace | +| `append_file` | Append to files | Only files within workspace | +| `exec` | Execute commands | Command paths must be within workspace | + +#### Additional Exec Protection + +Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands: + +- `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion +- `format`, `mkfs`, `diskpart` — Disk formatting +- `dd if=` — Disk imaging +- Writing to `/dev/sd[a-z]` — Direct disk writes +- `shutdown`, `reboot`, `poweroff` — System shutdown +- Fork bomb `:(){ :|:& };:` + +#### Error Examples + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Disabling Restrictions (Security Risk) + +If you need the agent to access paths outside the workspace: + +**Method 1: Config file** +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Method 2: Environment variable** +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Warning**: Disabling this restriction allows the agent to access any path on your system. Use with caution in controlled environments only. + +#### Security Boundary Consistency + +The `restrict_to_workspace` setting applies consistently across all execution paths: + +| Execution Path | Security Boundary | +|----------------|-------------------| +| Main Agent | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Inherits same restriction ✅ | +| Heartbeat tasks | Inherits same restriction ✅ | + +All paths share the same workspace restriction — there's no way to bypass the security boundary through subagents or scheduled tasks. + ### Heartbeat (Periodic Tasks) PicoClaw can perform periodic tasks automatically. Create a `HEARTBEAT.md` file in your workspace: @@ -636,7 +743,13 @@ Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically. PRs welcome! The codebase is intentionally small and readable. 🤗 -discord: +Roadmap coming soon... + +Developer group building, Entry Requirement: At least 1 Merged PR. + +User Groups: + +discord: PicoClaw diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000..f2c9bf7 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,719 @@ +
+PicoClaw + +

PicoClaw: 基于Go语言的超高效 AI 助手

+ +

10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+ Website + Twitter +

+ + **中文** | [日本語](README.ja.md) | [English](README.md) +
+ +--- + +🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。 + +⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%! + + + + + + +
+

+ +

+
+

+ +

+
+ +注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。 + +> [!CAUTION] +> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明** +> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。 +> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。 +> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。 +> +> + +## 📢 新闻 (News) + +2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。 +🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。 + +2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走! + +## ✨ 特性 + +🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。 + +💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。 + +⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。 + +🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行! + +🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。 + +| | OpenClaw | NanoBot | **PicoClaw** | +| --- | --- | --- | --- | +| **语言** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **启动时间**
(0.8GHz core) | >500s | >30s | **<1s** | +| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板**
**低至 $10** | + +PicoClaw + +## 🦾 演示 + +### 🛠️ 标准助手工作流 + + + + + + + + + + + + + + + + + +

🧩 全栈工程师模式

🗂️ 日志与规划管理

🔎 网络搜索与学习

开发 • 部署 • 扩展日程 • 自动化 • 记忆发现 • 洞察 • 趋势
+ +### 🐜 创新的低占用部署 + +PicoClaw 几乎可以部署在任何 Linux 设备上! + +* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。 +* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。 +* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。 + +[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4) + +🌟 更多部署案例敬请期待! + +## 📦 安装 + +### 使用预编译二进制文件安装 + +从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。 + +### 从源码安装(获取最新特性,开发推荐) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# 构建(无需安装) +make build + +# 为多平台构建 +make build-all + +# 构建并安装 +make install + +``` + +## 🐳 Docker Compose + +您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。 + +```bash +# 1. 克隆仓库 +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 等 + +# 3. 构建并启动 +docker compose --profile gateway up -d + +# 4. 查看日志 +docker compose logs -f picoclaw-gateway + +# 5. 停止 +docker compose --profile gateway down + +``` + +### Agent 模式 (一次性运行) + +```bash +# 提问 +docker compose run --rm picoclaw-agent -m "2+2 等于几?" + +# 交互模式 +docker compose run --rm picoclaw-agent + +``` + +### 重新构建 + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d + +``` + +### 🚀 快速开始 + +> [!TIP] +> 在 `~/.picoclaw/config.json` 中设置您的 API Key。 +> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询) + +**1. 初始化 (Initialize)** + +```bash +picoclaw onboard + +``` + +**2. 配置 (Configure)** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "openrouter": { + "api_key": "xxx", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "tools": { + "web": { + "search": { + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + } + } + } +} + +``` + +**3. 获取 API Key** + +* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月) + +> **注意**: 完整的配置模板请参考 `config.example.json`。 + +**4. 对话 (Chat)** + +```bash +picoclaw agent -m "2+2 等于几?" + +``` + +就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。 + +--- + +## 💬 聊天应用集成 (Chat Apps) + +通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。 + +| 渠道 | 设置难度 | +| --- | --- | +| **Telegram** | 简单 (仅需 token) | +| **Discord** | 简单 (bot token + intents) | +| **QQ** | 简单 (AppID + AppSecret) | +| **钉钉 (DingTalk)** | 中等 (app credentials) | + +
+Telegram (推荐) + +**1. 创建机器人** + +* 打开 Telegram,搜索 `@BotFather` +* 发送 `/newbot`,按照提示操作 +* 复制 token + +**2. 配置** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} + +``` + +> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。 + +**3. 运行** + +```bash +picoclaw gateway + +``` + +
+ +
+Discord + +**1. 创建机器人** + +* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications) +* Create an application → Bot → Add Bot +* 复制 bot token + +**2. 开启 Intents** + +* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT** +* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT** + +**3. 获取您的 User ID** + +* Discord 设置 → Advanced → 开启 **Developer Mode** +* 右键点击您的头像 → **Copy User ID** + +**4. 配置** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} + +``` + +**5. 邀请机器人** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* 打开生成的邀请 URL,将机器人添加到您的服务器 + +**6. 运行** + +```bash +picoclaw gateway + +``` + +
+ +
+QQ + +**1. 创建机器人** + +* 前往 [QQ 开放平台](https://connect.qq.com/) +* 创建应用 → 获取 **AppID** 和 **AppSecret** + +**2. 配置** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} + +``` + +> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。 + +**3. 运行** + +```bash +picoclaw gateway + +``` + +
+ +
+钉钉 (DingTalk) + +**1. 创建机器人** + +* 前往 [开放平台](https://open.dingtalk.com/) +* 创建内部应用 +* 复制 Client ID 和 Client Secret + +**2. 配置** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} + +``` + +> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。 + +**3. 运行** + +```bash +picoclaw gateway + +``` + +
+ +## ClawdChat 加入 Agent 社交网络 + +只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。 + +**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai) + +## ⚙️ 配置详解 + +配置文件路径: `~/.picoclaw/config.json` + +### 工作区布局 (Workspace Layout) + +PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # 对话会话和历史 +├── memory/ # 长期记忆 (MEMORY.md) +├── state/ # 持久化状态 (最后一次频道等) +├── cron/ # 定时任务数据库 +├── skills/ # 自定义技能 +├── AGENTS.md # Agent 行为指南 +├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) +├── IDENTITY.md # Agent 身份设定 +├── SOUL.md # Agent 灵魂/性格 +├── TOOLS.md # 工具描述 +└── USER.md # 用户偏好 + +``` + +### 心跳 / 周期性任务 (Heartbeat) + +PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件: + +```markdown +# Periodic Tasks + +- Check my email for important messages +- Review my calendar for upcoming events +- Check the weather forecast + +``` + +Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。 + +#### 使用 Spawn 的异步任务 + +对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**: + +```markdown +# Periodic Tasks + +## Quick Tasks (respond directly) +- Report current time + +## Long Tasks (use spawn for async) +- Search the web for AI news and summarize +- Check email and report important messages + +``` + +**关键行为:** + +| 特性 | 描述 | +| --- | --- | +| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 | +| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 | +| **message tool** | 子 Agent 通过 message 工具直接与用户通信 | +| **非阻塞** | spawn 后,心跳继续处理下一个任务 | + +#### 子 Agent 通信原理 + +``` +心跳触发 (Heartbeat triggers) + ↓ +Agent 读取 HEARTBEAT.md + ↓ +对于长任务: spawn 子 Agent + ↓ ↓ +继续下一个任务 子 Agent 独立工作 + ↓ ↓ +所有任务完成 子 Agent 使用 "message" 工具 + ↓ ↓ +响应 HEARTBEAT_OK 用户直接收到结果 + +``` + +子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。 + +**配置:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} + +``` + +| 选项 | 默认值 | 描述 | +| --- | --- | --- | +| `enabled` | `true` | 启用/禁用心跳 | +| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) | + +**环境变量:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用 +* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔 + +### 提供商 (Providers) + +> [!NOTE] +> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。 + +| 提供商 | 用途 | 获取 API Key | +| --- | --- | --- | +| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) | +| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | +| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | +| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | +| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | + +
+智谱 (Zhipu) 配置示例 + +**1. 获取 API key 和 base URL** + +* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. 配置** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + }, + }, +} + +``` + +**3. 运行** + +```bash +picoclaw agent -m "你好" + +``` + +
+ +
+完整配置示例 + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "search": { + "api_key": "BSA..." + } + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} + +``` + +
+ +## CLI 命令行参考 + +| 命令 | 描述 | +| --- | --- | +| `picoclaw onboard` | 初始化配置和工作区 | +| `picoclaw agent -m "..."` | 与 Agent 对话 | +| `picoclaw agent` | 交互式聊天模式 | +| `picoclaw gateway` | 启动网关 (Gateway) | +| `picoclaw status` | 显示状态 | +| `picoclaw cron list` | 列出所有定时任务 | +| `picoclaw cron add ...` | 添加定时任务 | + +### 定时任务 / 提醒 (Scheduled Tasks) + +PicoClaw 通过 `cron` 工具支持定时提醒和重复任务: + +* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次 +* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发 +* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式 + +任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。 + +## 🤝 贡献与路线图 (Roadmap) + +欢迎提交 PR!代码库刻意保持小巧和可读。🤗 + +路线图即将发布... + +开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。 + +用户群组: + +Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN) + +PicoClaw + +## 🐛 疑难解答 (Troubleshooting) + +### 网络搜索提示 "API 配置问题" + +如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。 + +启用网络搜索: + +1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询) +2. 添加到 `~/.picoclaw/config.json`: +```json +{ + "tools": { + "web": { + "search": { + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + } + } + } +} + +``` + + + +### 遇到内容过滤错误 (Content Filtering Errors) + +某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。 + +### Telegram bot 提示 "Conflict: terminated by other getUpdates" + +这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。 + +--- + +## 📝 API Key 对比 + +| 服务 | 免费层级 | 适用场景 | +| --- | --- | --- | +| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) | +| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 | +| **Brave Search** | 2000 次查询/月 | 网络搜索功能 | +| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) | \ No newline at end of file diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 8c00110..21246cf 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -36,21 +36,40 @@ import ( var ( version = "dev" + gitCommit string buildTime string goVersion string ) const logo = "🦞" -func printVersion() { - fmt.Printf("%s picoclaw %s\n", logo, version) - if buildTime != "" { - fmt.Printf(" Build: %s\n", buildTime) +// formatVersion returns the version string with optional git commit +func formatVersion() string { + v := version + if gitCommit != "" { + v += fmt.Sprintf(" (git: %s)", gitCommit) } - goVer := goVersion + return v +} + +// formatBuildInfo returns build time and go version info +func formatBuildInfo() (build string, goVer string) { + if buildTime != "" { + build = buildTime + } + goVer = goVersion if goVer == "" { goVer = runtime.Version() } + return +} + +func printVersion() { + fmt.Printf("%s picoclaw %s\n", logo, formatVersion()) + build, goVer := formatBuildInfo() + if build != "" { + fmt.Printf(" Build: %s\n", build) + } if goVer != "" { fmt.Printf(" Go: %s\n", goVer) } @@ -760,7 +779,13 @@ func statusCmd() { configPath := getConfigPath() - fmt.Printf("%s picoclaw Status\n\n", logo) + fmt.Printf("%s picoclaw Status\n", logo) + fmt.Printf("Version: %s\n", formatVersion()) + build, _ := formatBuildInfo() + if build != "" { + fmt.Printf("Build: %s\n", build) + } + fmt.Println() if _, err := os.Stat(configPath); err == nil { fmt.Println("Config:", configPath, "✓") @@ -1281,53 +1306,6 @@ func cronEnableCmd(storePath string, disable bool) { } } -func skillsCmd() { - if len(os.Args) < 3 { - skillsHelp() - return - } - - subcommand := os.Args[2] - - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - os.Exit(1) - } - - workspace := cfg.WorkspacePath() - installer := skills.NewSkillInstaller(workspace) - // 获取全局配置目录和内置 skills 目录 - globalDir := filepath.Dir(getConfigPath()) - globalSkillsDir := filepath.Join(globalDir, "skills") - builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") - skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) - - switch subcommand { - case "list": - skillsListCmd(skillsLoader) - case "install": - skillsInstallCmd(installer) - case "remove", "uninstall": - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills remove ") - return - } - skillsRemoveCmd(installer, os.Args[3]) - case "search": - skillsSearchCmd(installer) - case "show": - if len(os.Args) < 4 { - fmt.Println("Usage: picoclaw skills show ") - return - } - skillsShowCmd(skillsLoader, os.Args[3]) - default: - fmt.Printf("Unknown skills command: %s\n", subcommand) - skillsHelp() - } -} - func skillsHelp() { fmt.Println("\nSkills commands:") fmt.Println(" list List installed skills") diff --git a/config/config.openrouter.json b/config/config.openrouter.json deleted file mode 100644 index 4aca883..0000000 --- a/config/config.openrouter.json +++ /dev/null @@ -1,86 +0,0 @@ -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "model": "arcee-ai/trinity-large-preview:free", - "max_tokens": 8192, - "temperature": 0.7, - "max_tool_iterations": 20 - } - }, - "channels": { - "telegram": { - "enabled": false, - "token": "YOUR_TELEGRAM_BOT_TOKEN", - "allow_from": [ - "YOUR_USER_ID" - ] - }, - "discord": { - "enabled": true, - "token": "YOUR_DISCORD_BOT_TOKEN", - "allow_from": [] - }, - "maixcam": { - "enabled": false, - "host": "0.0.0.0", - "port": 18790, - "allow_from": [] - }, - "whatsapp": { - "enabled": false, - "bridge_url": "ws://localhost:3001", - "allow_from": [] - }, - "feishu": { - "enabled": false, - "app_id": "", - "app_secret": "", - "encrypt_key": "", - "verification_token": "", - "allow_from": [] - } - }, - "providers": { - "anthropic": { - "api_key": "", - "api_base": "" - }, - "openai": { - "api_key": "", - "api_base": "" - }, - "openrouter": { - "api_key": "sk-or-v1-xxx", - "api_base": "" - }, - "groq": { - "api_key": "gsk_xxx", - "api_base": "" - }, - "zhipu": { - "api_key": "YOUR_ZHIPU_API_KEY", - "api_base": "" - }, - "gemini": { - "api_key": "", - "api_base": "" - }, - "vllm": { - "api_key": "", - "api_base": "" - } - }, - "tools": { - "web": { - "search": { - "api_key": "YOUR_BRAVE_API_KEY", - "max_results": 5 - } - } - }, - "gateway": { - "host": "0.0.0.0", - "port": 18790 - } -} \ No newline at end of file diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index dfe466d..ac8da9f 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -374,7 +374,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str // 6. Save final assistant message to session al.sessions.AddMessage(opts.SessionKey, "assistant", finalContent) - al.sessions.Save(al.sessions.GetOrCreate(opts.SessionKey)) + al.sessions.Save(opts.SessionKey) // 7. Optional: summarization if opts.EnableSummary { @@ -738,7 +738,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) { if finalSummary != "" { al.sessions.SetSummary(sessionKey, finalSummary) al.sessions.TruncateHistory(sessionKey, 4) - al.sessions.Save(al.sessions.GetOrCreate(sessionKey)) + al.sessions.Save(sessionKey) } } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 3ad4818..3e1c40e 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -470,8 +470,11 @@ func extractCodeBlocks(text string) codeBlockMatch { codes = append(codes, match[1]) } + i := 0 text = re.ReplaceAllStringFunc(text, func(m string) string { - return fmt.Sprintf("\x00CB%d\x00", len(codes)-1) + placeholder := fmt.Sprintf("\x00CB%d\x00", i) + i++ + return placeholder }) return codeBlockMatch{text: text, codes: codes} @@ -491,8 +494,11 @@ func extractInlineCodes(text string) inlineCodeMatch { codes = append(codes, match[1]) } + i := 0 text = re.ReplaceAllStringFunc(text, func(m string) string { - return fmt.Sprintf("\x00IC%d\x00", len(codes)-1) + placeholder := fmt.Sprintf("\x00IC%d\x00", i) + i++ + return placeholder }) return inlineCodeMatch{text: text, codes: codes} diff --git a/pkg/cron/service.go b/pkg/cron/service.go index 841db0f..ddd680e 100644 --- a/pkg/cron/service.go +++ b/pkg/cron/service.go @@ -71,7 +71,6 @@ func NewCronService(storePath string, onJob JobHandler) *CronService { cs := &CronService{ storePath: storePath, onJob: onJob, - stopChan: make(chan struct{}), gronx: gronx.New(), } // Initialize and load store on creation @@ -96,8 +95,9 @@ func (cs *CronService) Start() error { return fmt.Errorf("failed to save store: %w", err) } + cs.stopChan = make(chan struct{}) cs.running = true - go cs.runLoop() + go cs.runLoop(cs.stopChan) return nil } @@ -111,16 +111,19 @@ func (cs *CronService) Stop() { } cs.running = false - close(cs.stopChan) + if cs.stopChan != nil { + close(cs.stopChan) + cs.stopChan = nil + } } -func (cs *CronService) runLoop() { +func (cs *CronService) runLoop(stopChan chan struct{}) { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { - case <-cs.stopChan: + case <-stopChan: return case <-ticker.C: cs.checkJobs() @@ -137,27 +140,23 @@ func (cs *CronService) checkJobs() { } now := time.Now().UnixMilli() - var dueJobs []*CronJob + var dueJobIDs []string // Collect jobs that are due (we need to copy them to execute outside lock) for i := range cs.store.Jobs { job := &cs.store.Jobs[i] if job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now { - // Create a shallow copy of the job for execution - jobCopy := *job - dueJobs = append(dueJobs, &jobCopy) + dueJobIDs = append(dueJobIDs, job.ID) } } - // Update next run times for due jobs immediately (before executing) - // Use map for O(n) lookup instead of O(n²) nested loop - dueMap := make(map[string]bool, len(dueJobs)) - for _, job := range dueJobs { - dueMap[job.ID] = true + // Reset next run for due jobs before unlocking to avoid duplicate execution. + dueMap := make(map[string]bool, len(dueJobIDs)) + for _, jobID := range dueJobIDs { + dueMap[jobID] = true } for i := range cs.store.Jobs { if dueMap[cs.store.Jobs[i].ID] { - // Reset NextRunAtMS temporarily so we don't re-execute cs.store.Jobs[i].State.NextRunAtMS = nil } } @@ -168,53 +167,75 @@ func (cs *CronService) checkJobs() { cs.mu.Unlock() - // Execute jobs outside the lock - for _, job := range dueJobs { - cs.executeJob(job) + // Execute jobs outside lock. + for _, jobID := range dueJobIDs { + cs.executeJobByID(jobID) } } -func (cs *CronService) executeJob(job *CronJob) { +func (cs *CronService) executeJobByID(jobID string) { startTime := time.Now().UnixMilli() + cs.mu.RLock() + var callbackJob *CronJob + for i := range cs.store.Jobs { + job := &cs.store.Jobs[i] + if job.ID == jobID { + jobCopy := *job + callbackJob = &jobCopy + break + } + } + cs.mu.RUnlock() + + if callbackJob == nil { + return + } + var err error if cs.onJob != nil { - _, err = cs.onJob(job) + _, err = cs.onJob(callbackJob) } // Now acquire lock to update state cs.mu.Lock() defer cs.mu.Unlock() - // Find the job in store and update it + var job *CronJob for i := range cs.store.Jobs { - if cs.store.Jobs[i].ID == job.ID { - cs.store.Jobs[i].State.LastRunAtMS = &startTime - cs.store.Jobs[i].UpdatedAtMS = time.Now().UnixMilli() - - if err != nil { - cs.store.Jobs[i].State.LastStatus = "error" - cs.store.Jobs[i].State.LastError = err.Error() - } else { - cs.store.Jobs[i].State.LastStatus = "ok" - cs.store.Jobs[i].State.LastError = "" - } - - // Compute next run time - if cs.store.Jobs[i].Schedule.Kind == "at" { - if cs.store.Jobs[i].DeleteAfterRun { - cs.removeJobUnsafe(job.ID) - } else { - cs.store.Jobs[i].Enabled = false - cs.store.Jobs[i].State.NextRunAtMS = nil - } - } else { - nextRun := cs.computeNextRun(&cs.store.Jobs[i].Schedule, time.Now().UnixMilli()) - cs.store.Jobs[i].State.NextRunAtMS = nextRun - } + if cs.store.Jobs[i].ID == jobID { + job = &cs.store.Jobs[i] break } } + if job == nil { + log.Printf("[cron] job %s disappeared before state update", jobID) + return + } + + job.State.LastRunAtMS = &startTime + job.UpdatedAtMS = time.Now().UnixMilli() + + if err != nil { + job.State.LastStatus = "error" + job.State.LastError = err.Error() + } else { + job.State.LastStatus = "ok" + job.State.LastError = "" + } + + // Compute next run time + if job.Schedule.Kind == "at" { + if job.DeleteAfterRun { + cs.removeJobUnsafe(job.ID) + } else { + job.Enabled = false + job.State.NextRunAtMS = nil + } + } else { + nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli()) + job.State.NextRunAtMS = nextRun + } if err := cs.saveStoreUnsafe(); err != nil { log.Printf("[cron] failed to save store: %v", err) diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go index a090cda..dfdaef5 100644 --- a/pkg/heartbeat/service.go +++ b/pkg/heartbeat/service.go @@ -40,7 +40,6 @@ type HeartbeatService struct { interval time.Duration enabled bool mu sync.RWMutex - started bool stopChan chan struct{} } @@ -60,7 +59,6 @@ func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *H interval: time.Duration(intervalMinutes) * time.Minute, enabled: enabled, state: state.NewManager(workspace), - stopChan: make(chan struct{}), } } @@ -83,7 +81,7 @@ func (hs *HeartbeatService) Start() error { hs.mu.Lock() defer hs.mu.Unlock() - if hs.started { + if hs.stopChan != nil { logger.InfoC("heartbeat", "Heartbeat service already running") return nil } @@ -93,10 +91,8 @@ func (hs *HeartbeatService) Start() error { return nil } - hs.started = true hs.stopChan = make(chan struct{}) - - go hs.runLoop() + go hs.runLoop(hs.stopChan) logger.InfoCF("heartbeat", "Heartbeat service started", map[string]any{ "interval_minutes": hs.interval.Minutes(), @@ -110,24 +106,24 @@ func (hs *HeartbeatService) Stop() { hs.mu.Lock() defer hs.mu.Unlock() - if !hs.started { + if hs.stopChan == nil { return } logger.InfoC("heartbeat", "Stopping heartbeat service") close(hs.stopChan) - hs.started = false + hs.stopChan = nil } // IsRunning returns whether the service is running func (hs *HeartbeatService) IsRunning() bool { hs.mu.RLock() defer hs.mu.RUnlock() - return hs.started + return hs.stopChan != nil } // runLoop runs the heartbeat ticker -func (hs *HeartbeatService) runLoop() { +func (hs *HeartbeatService) runLoop(stopChan chan struct{}) { ticker := time.NewTicker(hs.interval) defer ticker.Stop() @@ -138,7 +134,7 @@ func (hs *HeartbeatService) runLoop() { for { select { - case <-hs.stopChan: + case <-stopChan: return case <-ticker.C: hs.executeHeartbeat() @@ -149,8 +145,12 @@ func (hs *HeartbeatService) runLoop() { // executeHeartbeat performs a single heartbeat check func (hs *HeartbeatService) executeHeartbeat() { hs.mu.RLock() - enabled := hs.enabled && hs.started + enabled := hs.enabled handler := hs.handler + if !hs.enabled || hs.stopChan == nil { + hs.mu.RUnlock() + return + } hs.mu.RUnlock() if !enabled { diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go index d7aed15..a2b59e3 100644 --- a/pkg/heartbeat/service_test.go +++ b/pkg/heartbeat/service_test.go @@ -17,7 +17,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) { defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 30, true) - hs.started = true // Enable for testing + hs.stopChan = make(chan struct{}) // Enable for testing asyncCalled := false asyncResult := &tools.ToolResult{ @@ -55,7 +55,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) { defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 30, true) - hs.started = true // Enable for testing + hs.stopChan = make(chan struct{}) // Enable for testing hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { return &tools.ToolResult{ @@ -93,7 +93,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) { defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 30, true) - hs.started = true // Enable for testing + hs.stopChan = make(chan struct{}) // Enable for testing hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { return &tools.ToolResult{ @@ -167,7 +167,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) { defer os.RemoveAll(tmpDir) hs := NewHeartbeatService(tmpDir, 30, true) - hs.started = true // Enable for testing + hs.stopChan = make(chan struct{}) // Enable for testing hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult { return nil diff --git a/pkg/session/manager.go b/pkg/session/manager.go index b4b8257..193ad2b 100644 --- a/pkg/session/manager.go +++ b/pkg/session/manager.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "strings" "sync" "time" @@ -39,22 +40,22 @@ func NewSessionManager(storage string) *SessionManager { } func (sm *SessionManager) GetOrCreate(key string) *Session { - sm.mu.RLock() - session, ok := sm.sessions[key] - sm.mu.RUnlock() + sm.mu.Lock() + defer sm.mu.Unlock() - if !ok { - sm.mu.Lock() - session = &Session{ - Key: key, - Messages: []providers.Message{}, - Created: time.Now(), - Updated: time.Now(), - } - sm.sessions[key] = session - sm.mu.Unlock() + session, ok := sm.sessions[key] + if ok { + return session } + session = &Session{ + Key: key, + Messages: []providers.Message{}, + Created: time.Now(), + Updated: time.Now(), + } + sm.sessions[key] = session + return session } @@ -130,6 +131,12 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { return } + if keepLast <= 0 { + session.Messages = []providers.Message{} + session.Updated = time.Now() + return + } + if len(session.Messages) <= keepLast { return } @@ -138,22 +145,78 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) { session.Updated = time.Now() } -func (sm *SessionManager) Save(session *Session) error { +func (sm *SessionManager) Save(key string) error { if sm.storage == "" { return nil } - sm.mu.Lock() - defer sm.mu.Unlock() + // Validate key to avoid invalid filenames and path traversal. + if key == "" || key == "." || key == ".." || key != filepath.Base(key) || strings.Contains(key, "/") || strings.Contains(key, "\\") { + return os.ErrInvalid + } - sessionPath := filepath.Join(sm.storage, session.Key+".json") + // Snapshot under read lock, then perform slow file I/O after unlock. + sm.mu.RLock() + stored, ok := sm.sessions[key] + if !ok { + sm.mu.RUnlock() + return nil + } - data, err := json.MarshalIndent(session, "", " ") + snapshot := Session{ + Key: stored.Key, + Summary: stored.Summary, + Created: stored.Created, + Updated: stored.Updated, + } + if len(stored.Messages) > 0 { + snapshot.Messages = make([]providers.Message, len(stored.Messages)) + copy(snapshot.Messages, stored.Messages) + } else { + snapshot.Messages = []providers.Message{} + } + sm.mu.RUnlock() + + data, err := json.MarshalIndent(snapshot, "", " ") if err != nil { return err } - return os.WriteFile(sessionPath, data, 0644) + sessionPath := filepath.Join(sm.storage, key+".json") + tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp") + if err != nil { + return err + } + + tmpPath := tmpFile.Name() + cleanup := true + defer func() { + if cleanup { + _ = os.Remove(tmpPath) + } + }() + + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Chmod(0644); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Sync(); err != nil { + _ = tmpFile.Close() + return err + } + if err := tmpFile.Close(); err != nil { + return err + } + + if err := os.Rename(tmpPath, sessionPath); err != nil { + return err + } + cleanup = false + return nil } func (sm *SessionManager) loadSessions() error { diff --git a/tasks/prd-tool-result-refactor.md b/tasks/prd-tool-result-refactor.md deleted file mode 100644 index c0e984d..0000000 --- a/tasks/prd-tool-result-refactor.md +++ /dev/null @@ -1,293 +0,0 @@ -# PRD: Tool 返回值结构化重构 - -## Introduction - -当前 picoclaw 的 Tool 接口返回 `(string, error)`,存在以下问题: - -1. **语义不明确**:返回的字符串是给 LLM 看还是给用户看,无法区分 -2. **字符串匹配黑魔法**:`isToolConfirmationMessage` 靠字符串包含判断是否发送给用户,容易误判 -3. **无法支持异步任务**:心跳触发长任务时会一直阻塞,影响定时器 -4. **状态保存不原子**:`SetLastChannel` 和 `Save` 分离,崩溃时状态不一致 - -本重构将 Tool 返回值改为结构化的 `ToolResult`,明确区分 `ForLLM`(给 AI 看)和 `ForUser`(给用户看),支持异步任务和回调通知,删除字符串匹配逻辑。 - -## Goals - -- Tool 返回结构化的 `ToolResult`,明确区分 LLM 内容和用户内容 -- 支持异步任务执行,心跳触发后不等待完成 -- 异步任务完成时通过回调通知系统 -- 删除 `isToolConfirmationMessage` 字符串匹配黑魔法 -- 状态保存原子化,防止数据不一致 -- 为所有改造添加完整测试覆盖 - -## User Stories - -### US-001: 新增 ToolResult 结构体和辅助函数 -**Description:** 作为开发者,我需要定义新的 ToolResult 结构体和辅助构造函数,以便工具可以明确表达返回结果的语义。 - -**Acceptance Criteria:** -- [ ] `ToolResult` 包含字段:ForLLM, ForUser, Silent, IsError, Async, Err -- [ ] 提供辅助函数:NewToolResult(), SilentResult(), AsyncResult(), ErrorResult(), UserResult() -- [ ] ToolResult 支持 JSON 序列化(除 Err 字段) -- [ ] 添加完整 godoc 注释 -- [ ] `go test ./pkg/tools -run TestToolResult` 通过 - -### US-002: 修改 Tool 接口返回值 -**Description:** 作为开发者,我需要将 Tool 接口的 Execute 方法返回值从 `(string, error)` 改为 `*ToolResult`,以便使用新的结构化返回值。 - -**Acceptance Criteria:** -- [ ] `pkg/tools/base.go` 中 `Tool.Execute()` 签名改为返回 `*ToolResult` -- [ ] 所有实现了 Tool 接口的类型更新方法签名 -- [ ] `go build ./...` 无编译错误 -- [ ] `go vet ./...` 通过 - -### US-003: 修改 ToolRegistry 处理 ToolResult -**Description:** 作为中间层,ToolRegistry 需要处理新的 ToolResult 返回值,并调整日志逻辑以反映异步任务状态。 - -**Acceptance Criteria:** -- [ ] `ExecuteWithContext()` 返回值改为 `*ToolResult` -- [ ] 日志区分:completed / async / failed 三种状态 -- [ ] 异步任务记录启动日志而非完成日志 -- [ ] 错误日志包含 ToolResult.Err 内容 -- [ ] `go test ./pkg/tools -run TestRegistry` 通过 - -### US-004: 删除 isToolConfirmationMessage 字符串匹配 -**Description:** 作为代码维护者,我需要删除 `isToolConfirmationMessage` 函数及相关调用,因为 ToolResult.Silent 字段已经解决了这个问题。 - -**Acceptance Criteria:** -- [ ] 删除 `pkg/agent/loop.go` 中的 `isToolConfirmationMessage` 函数 -- [ ] `runAgentLoop` 中移除对该函数的调用 -- [ ] 工具结果是否发送由 ToolResult.Silent 决定 -- [ ] `go build ./...` 无编译错误 - -### US-005: 修改 AgentLoop 工具结果处理逻辑 -**Description:** 作为 agent 主循环,我需要根据 ToolResult 的字段决定如何处理工具执行结果。 - -**Acceptance Criteria:** -- [ ] LLM 收到的消息内容来自 ToolResult.ForLLM -- [ ] 用户收到的消息优先使用 ToolResult.ForUser,其次使用 LLM 最终回复 -- [ ] ToolResult.Silent 为 true 时不发送用户消息 -- [ ] 记录最后执行的工具结果以便后续判断 -- [ ] `go test ./pkg/agent -run TestLoop` 通过 - -### US-006: 心跳支持异步任务执行 -**Description:** 作为心跳服务,我需要触发异步任务后立即返回,不等待任务完成,避免阻塞定时器。 - -**Acceptance Criteria:** -- [ ] `ExecuteHeartbeatWithTools` 检测 ToolResult.Async 标记 -- [ ] 异步任务返回 "Task started in background" 给 LLM -- [ ] 异步任务不阻塞心跳流程 -- [ ] 删除重复的 `ProcessHeartbeat` 函数 -- [ ] `go test ./pkg/heartbeat -run TestAsync` 通过 - -### US-007: 异步任务完成回调机制 -**Description:** 作为系统,我需要支持异步任务完成后的回调通知,以便任务结果能正确发送给用户。 - -**Acceptance Criteria:** -- [ ] 定义 AsyncCallback 函数类型:`func(ctx context.Context, result *ToolResult)` -- [ ] Tool 添加可选接口 `AsyncTool`,包含 `SetCallback(cb AsyncCallback)` -- [ ] 执行异步工具时注入回调函数 -- [ ] 工具内部 goroutine 完成后调用回调 -- [ ] 回调通过 SendToChannel 发送结果给用户 -- [ ] `go test ./pkg/tools -run TestAsyncCallback` 通过 - -### US-008: 状态保存原子化 -**Description:** 作为状态管理,我需要确保状态更新和保存是原子操作,防止程序崩溃时数据不一致。 - -**Acceptance Criteria:** -- [ ] `SetLastChannel` 合并保存逻辑,接受 workspace 参数 -- [ ] 使用临时文件 + rename 实现原子写入 -- [ ] rename 失败时清理临时文件 -- [ ] 更新时间戳在锁内完成 -- [ ] `go test ./pkg/state -run TestAtomicSave` 通过 - -### US-009: 改造 MessageTool -**Description:** 作为消息发送工具,我需要使用新的 ToolResult 返回值,发送成功后静默不通知用户。 - -**Acceptance Criteria:** -- [ ] 发送成功返回 `SilentResult("Message sent to ...")` -- [ ] 发送失败返回 `ErrorResult(...)` -- [ ] ForLLM 包含发送状态描述 -- [ ] ForUser 为空(用户已直接收到消息) -- [ ] `go test ./pkg/tools -run TestMessageTool` 通过 - -### US-010: 改造 ShellTool -**Description:** 作为 shell 命令工具,我需要将命令结果发送给用户,失败时显示错误信息。 - -**Acceptance Criteria:** -- [ ] 成功返回包含 ForUser = 命令输出的 ToolResult -- [ ] 失败返回 IsError = true 的 ToolResult -- [ ] ForLLM 包含完整输出和退出码 -- [ ] `go test ./pkg/tools -run TestShellTool` 通过 - -### US-011: 改造 FilesystemTool -**Description:** 作为文件操作工具,我需要静默完成文件读写,不向用户发送确认消息。 - -**Acceptance Criteria:** -- [ ] 所有文件操作返回 `SilentResult(...)` -- [ ] 错误时返回 `ErrorResult(...)` -- [ ] ForLLM 包含操作摘要(如 "File updated: /path/to/file") -- [ ] `go test ./pkg/tools -run TestFilesystemTool` 通过 - -### US-012: 改造 WebTool -**Description:** 作为网络请求工具,我需要将抓取的内容发送给用户查看。 - -**Acceptance Criteria:** -- [ ] 成功时 ForUser 包含抓取的内容 -- [ ] ForLLM 包含内容摘要和字节数 -- [ ] 失败时返回 ErrorResult -- [ ] `go test ./pkg/tools -run TestWebTool` 通过 - -### US-013: 改造 EditTool -**Description:** 作为文件编辑工具,我需要静默完成编辑,避免重复内容发送给用户。 - -**Acceptance Criteria:** -- [ ] 编辑成功返回 `SilentResult("File edited: ...")` -- [ ] ForLLM 包含编辑摘要 -- [ ] `go test ./pkg/tools -run TestEditTool` 通过 - -### US-014: 改造 CronTool -**Description:** 作为定时任务工具,我需要静默完成 cron 操作,不发送确认消息。 - -**Acceptance Criteria:** -- [ ] 所有 cron 操作返回 `SilentResult(...)` -- [ ] ForLLM 包含操作摘要(如 "Cron job added: daily-backup") -- [ ] `go test ./pkg/tools -run TestCronTool` 通过 - -### US-015: 改造 SpawnTool -**Description:** 作为子代理生成工具,我需要标记为异步任务,并通过回调通知完成。 - -**Acceptance Criteria:** -- [ ] 实现 `AsyncTool` 接口 -- [ ] 返回 `AsyncResult("Subagent spawned, will report back")` -- [ ] 子代理完成时调用回调发送结果 -- [ ] `go test ./pkg/tools -run TestSpawnTool` 通过 - -### US-016: 改造 SubagentTool -**Description:** 作为子代理工具,我需要将子代理的执行摘要发送给用户。 - -**Acceptance Criteria:** -- [ ] ForUser 包含子代理的输出摘要 -- [ ] ForLLM 包含完整执行详情 -- [ ] `go test ./pkg/tools -run TestSubagentTool` 通过 - -### US-017: 心跳配置默认启用 -**Description:** 作为系统配置,心跳功能应该默认启用,因为这是核心功能。 - -**Acceptance Criteria:** -- [ ] `DefaultConfig()` 中 `Heartbeat.Enabled` 改为 `true` -- [ ] 可通过环境变量 `PICOCLAW_HEARTBEAT_ENABLED=false` 覆盖 -- [ ] 配置文档更新说明默认启用 -- [ ] `go test ./pkg/config -run TestDefaultConfig` 通过 - -### US-018: 心跳日志写入 memory 目录 -**Description:** 作为心跳服务,日志应该写入 memory 目录以便被 LLM 访问和纳入知识系统。 - -**Acceptance Criteria:** -- [ ] 日志路径从 `workspace/heartbeat.log` 改为 `workspace/memory/heartbeat.log` -- [ ] 目录不存在时自动创建 -- [ ] 日志格式保持不变 -- [ ] `go test ./pkg/heartbeat -run TestLogPath` 通过 - -### US-019: 心跳调用 ExecuteHeartbeatWithTools -**Description:** 作为心跳服务,我需要调用支持异步的工具执行方法。 - -**Acceptance Criteria:** -- [ ] `executeHeartbeat` 调用 `handler.ExecuteHeartbeatWithTools(...)` -- [ ] 删除废弃的 `ProcessHeartbeat` 函数 -- [ ] `go build ./...` 无编译错误 - -### US-020: RecordLastChannel 调用原子化方法 -**Description:** 作为 AgentLoop,我需要调用新的原子化状态保存方法。 - -**Acceptance Criteria:** -- [ ] `RecordLastChannel` 调用 `st.SetLastChannel(al.workspace, lastChannel)` -- [ ] 传参包含 workspace 路径 -- [ ] `go test ./pkg/agent -run TestRecordLastChannel` 通过 - -## Functional Requirements - -- FR-1: ToolResult 结构体包含 ForLLM, ForUser, Silent, IsError, Async, Err 字段 -- FR-2: 提供 5 个辅助构造函数:NewToolResult, SilentResult, AsyncResult, ErrorResult, UserResult -- FR-3: Tool 接口 Execute 方法返回 `*ToolResult` -- FR-4: ToolRegistry 处理 ToolResult 并记录日志(区分 async/completed/failed) -- FR-5: AgentLoop 根据 ToolResult.Silent 决定是否发送用户消息 -- FR-6: 异步任务不阻塞心跳流程,返回 "Task started in background" -- FR-7: 工具可实现 AsyncTool 接口接收完成回调 -- FR-8: 状态保存使用临时文件 + rename 实现原子操作 -- FR-9: 心跳默认启用(Enabled: true) -- FR-10: 心跳日志写入 `workspace/memory/heartbeat.log` - -## Non-Goals (Out of Scope) - -- 不支持工具返回复杂对象(仅结构化文本) -- 不实现任务队列系统(异步任务由工具自己管理) -- 不支持异步任务超时取消 -- 不实现异步任务状态查询 API -- 不修改 LLMProvider 接口 -- 不支持嵌套异步任务 - -## Design Considerations - -### ToolResult 设计原则 -- **ForLLM**: 给 AI 看的内容,用于推理和决策 -- **ForUser**: 给用户看的内容,会通过 channel 发送 -- **Silent**: 为 true 时完全不发送用户消息 -- **Async**: 为 true 时任务在后台执行,立即返回 - -### 异步任务流程 -``` -心跳触发 → LLM 调用工具 → 工具返回 AsyncResult - ↓ - 工具启动 goroutine - ↓ - 任务完成 → 回调通知 → SendToChannel -``` - -### 原子写入实现 -```go -// 写入临时文件 -os.WriteFile(path + ".tmp", data, 0644) -// 原子重命名 -os.Rename(path + ".tmp", path) -``` - -## Technical Considerations - -- **破坏性变更**:所有工具实现需要同步修改,不支持向后兼容 -- **Go 版本**:需要 Go 1.21+(确保 atomic 操作支持) -- **测试覆盖**:每个改造的工具需要添加测试用例 -- **并发安全**:State 的原子操作需要正确使用锁 -- **回调设计**:AsyncTool 接口可选,不强制所有工具实现 - -### 回调函数签名 -```go -type AsyncCallback func(ctx context.Context, result *ToolResult) - -type AsyncTool interface { - Tool - SetCallback(cb AsyncCallback) -} -``` - -## Success Metrics - -- 删除 `isToolConfirmationMessage` 后无功能回归 -- 心跳可以触发长任务(如邮件检查)而不阻塞 -- 所有工具改造后测试覆盖率 > 80% -- 状态保存异常情况下无数据丢失 - -## Open Questions - -- [ ] 异步任务失败时如何通知用户?(通过回调发送错误消息) -- [ ] 异步任务是否需要超时机制?(暂不实现,由工具自己处理) -- [ ] 心跳日志是否需要 rotation?(暂不实现,使用外部 logrotate) - -## Implementation Order - -1. **基础设施**:ToolResult + Tool 接口 + Registry (US-001, US-002, US-003) -2. **消费者改造**:AgentLoop 工具结果处理 + 删除字符串匹配 (US-004, US-005) -3. **简单工具验证**:MessageTool 改造验证设计 (US-009) -4. **批量工具改造**:剩余所有工具 (US-010 ~ US-016) -5. **心跳和配置**:心跳异步支持 + 配置修改 (US-006, US-017, US-018, US-019) -6. **状态保存**:原子化保存 (US-008, US-020)