diff --git a/.cursorindexingignore b/.cursorindexingignore new file mode 100644 index 000000000..953908e73 --- /dev/null +++ b/.cursorindexingignore @@ -0,0 +1,3 @@ + +# Don't index SpecStory auto-save files, but allow explicit context inclusion via @ references +.specstory/** diff --git a/.specstory/.gitignore b/.specstory/.gitignore new file mode 100644 index 000000000..c5b4129ef --- /dev/null +++ b/.specstory/.gitignore @@ -0,0 +1,4 @@ +# SpecStory project identity file +/.project.json +# SpecStory explanation file +/.what-is-this.md diff --git a/.specstory/history/2026-02-25_09-23Z-current-branch-status.md b/.specstory/history/2026-02-25_09-23Z-current-branch-status.md new file mode 100644 index 000000000..4d968c8b8 --- /dev/null +++ b/.specstory/history/2026-02-25_09-23Z-current-branch-status.md @@ -0,0 +1,266 @@ + + + + +# Current branch status (2026-02-25 09:23Z) + +_**User (2026-02-25 09:23Z)**_ + +查看当前分支情况 + +--- + +_**Agent (model claude-4.5-sonnet-thinking, mode Agent)**_ + +
Thought Process +用户要查看当前分支情况。我需要运行 git 命令来查看当前分支状态。最常用的命令是: +- `git branch` - 查看本地分支 +- `git status` - 查看当前分支和状态 +- `git branch -a` - 查看所有分支(包括远程) + +让我先运行 git status 和 git branch 来查看当前分支情况。
+ +--- + +我来帮你查看当前的分支情况。 + +--- + + +
Tool use: **run_terminal_cmd** • Run command: cd /home/0668001505/Desktop/picoclaw/picoclaw && git branch -a + +```bash +cd /home/0668001505/Desktop/picoclaw/picoclaw && git branch -a +``` + +``` +* main + remotes/origin/HEAD -> origin/main + remotes/origin/feat/agent-browser-tool + remotes/origin/feat/mcp-context7-integration-cleanup + remotes/origin/main + remotes/origin/refactor/channel-system + +``` +
+
+ +--- + + +
Tool use: **run_terminal_cmd** • Run command: cd /home/0668001505/Desktop/picoclaw/picoclaw && git status + +```bash +cd /home/0668001505/Desktop/picoclaw/picoclaw && git status +``` + +``` +位于分支 main +您的分支与上游分支 'origin/main' 一致。 + +未跟踪的文件: + (使用 "git add <文件>..." 以包含要提交的内容) + .cursorindexingignore + .specstory/ + +提交为空,但是存在尚未跟踪的文件(使用 "git add" 建立跟踪) + +``` +
+
+ +--- + +
Thought Process +好的,我已经获取了分支信息。让我总结一下: + +1. 当前在 main 分支上 +2. 本地分支只有 main +3. 远程分支有: + - origin/main + - origin/feat/agent-browser-tool + - origin/feat/mcp-context7-integration-cleanup + - origin/refactor/channel-system +4. 当前分支与上游分支 origin/main 一致 +5. 有一些未跟踪的文件(.cursorindexingignore 和 .specstory/) + +现在我用中文总结给用户。
+ +--- + +根据查看结果,当前分支情况如下: + +## 📍 当前分支 +- **当前所在分支**: `main` +- **状态**: 与上游分支 `origin/main` 一致 + +## 📋 本地分支 +- `main` (当前分支) + +## 🌐 远程分支 +- `origin/main` +- `origin/feat/agent-browser-tool` +- `origin/feat/mcp-context7-integration-cleanup` +- `origin/refactor/channel-system` + +## 📝 工作区状态 +- 工作区干净,没有待提交的更改 +- 有未跟踪的文件: + - `.cursorindexingignore` + - `.specstory/` + +你目前在主分支上,且与远程保持同步。有几个特性分支在远程仓库中,如果需要切换到其他分支进行开发,可以使用 `git checkout` 命令。 + +--- + +_**User (2026-02-25 09:23Z)**_ + +ZeroClaw 是一个高性能、低资源占用、可组合的自主智能体运行时。ZeroClaw 是面向智能代理工作流的运行时操作系统,通常被视为 OpenClaw 的 Rust 重写版本。 它抽象了模型、工具、记忆和执行层,使代理可以一次构建、随处运行,它通过 Trait 驱动架构实现了模型、通信渠道、工具、记忆系统等组件的可插拔替换,旨在提供一个安全、快速且资源友好的自主智能体运行环境。 +核心定位:定位为生产级“Agent 操作系统”(Agent OS),适用于边缘设备、高并发 Agent 集群、自动化运维等场景。 + +技术栈:采用 Rust 编写,单二进制部署,跨 ARM / x86 / RISC-V,单文件体积极小(3.4MB 或 8.8MB 版本),内存占用极低(低于 5MB),启动速度极快(启动时间可低至毫秒级)。 + +安全设计:内置安全沙箱、配对鉴权(pairing authentication)和显式 allowlist,默认安全性高。 + +特性:支持 17+ 多种通信渠道(Telegram、Discord、Slack 等),支持 22+ AI 提供商(OpenAI、Claude、Ollama 等),支持本地模型运行 + + + + +参考上面zeroclaw ,给出本项目的描述 + +--- + +_**Agent (model claude-4.5-sonnet-thinking, mode Agent)**_ + +
Thought Process +用户希望我参考 ZeroClaw 的描述风格,给出当前项目 picoclaw 的描述。我需要先了解一下 picoclaw 项目的实际情况,然后基于项目的真实特性,仿照 ZeroClaw 的描述风格来撰写。 + +让我先查看一下项目的基本信息,比如 README、package.json 或其他配置文件。
+ +--- + +我来帮你了解项目并给出描述。首先让我查看一下项目的基本信息。 + +--- + + +
Tool use: **read_file** • Read file: /home/0668001505/Desktop/picoclaw/picoclaw/README.md + +
+
+ +--- + +Could not find file '/home/0668001505/Desktop/picoclaw/picoclaw/package.json' in the workspace. + +--- + + +
+ Tool use: **list_dir** • Listed directory, 0 results + +No results found +
+
+ +--- + +
Thought Process +好的,现在我了解了 picoclaw 项目的情况。这是一个用 Go 语言编写的超轻量级 AI 助手,与 ZeroClaw(Rust 编写)的定位类似,但更注重极致的资源效率。 + +让我根据 README 的内容,参考 ZeroClaw 的描述风格,给出 picoclaw 的项目描述。
+ +--- + +根据项目信息,我为你撰写了 PicoClaw 的描述,参考了 ZeroClaw 的风格: + +--- + +## PicoClaw 项目描述 + +**PicoClaw 是一个超轻量、极低资源占用的个人 AI 助手运行时。** PicoClaw 受 nanobot 启发,从 TypeScript 完全重写为 Go 语言,通过 AI 自举过程(self-bootstrapping)完成架构迁移和代码优化,是 OpenClaw 的极致精简版本。它抽象了模型、工具、通信渠道和记忆系统,使智能代理可以一次构建、随处运行,旨在将 AI 助手带入 $10 硬件和 <10MB 内存的极限环境。 + +### 核心定位 +定位为**超轻量级"个人 AI 助手操作系统"(Personal AI Assistant OS)**,适用于边缘设备、低功耗硬件(RISC-V单板机)、旧手机复活、智能家居、服务器自动化运维等场景,让 AI 智能体能够在资源受限的环境中高效运行。 + +### 技术栈 +- **开发语言**:采用 Go 编写,单二进制部署,无任何外部依赖 +- **跨平台支持**:原生支持 x86_64、ARM64、RISC-V 架构 +- **极致轻量**: + - 内存占用:**<10MB**(比 OpenClaw 减少 99%,比 NanoBot 减少 90%) + - 启动速度:**<1秒**(即使在 0.6GHz 单核处理器上,比 OpenClaw 快 400 倍) + - 单文件体积:约 10MB 左右 +- **成本优势**:可运行在 $10 硬件上(比 Mac mini 便宜 98%) + +### 安全设计 +- **内置安全沙箱**:默认限制文件和命令访问范围(`restrict_to_workspace`) +- **白名单机制**:通过 `allow_from` 配置显式授权用户访问 +- **危险命令拦截**:自动阻止 `rm -rf`、`format`、`shutdown` 等危险操作 +- **一致性安全边界**:主 Agent、子 Agent、定时任务共享相同安全限制 + +### 核心特性 + +#### 🌐 多通道支持(7+ 通信平台) +- **即时通讯**:Telegram、Discord、QQ、钉钉、LINE、企业微信(WeCom) +- **灵活接入**:通过配置文件即可启用/禁用任意通道 + +#### 🤖 多模型支持(15+ AI 提供商) +- **主流大模型**:OpenAI、Anthropic (Claude)、Google Gemini、智谱 AI (GLM)、通义千问 (Qwen)、DeepSeek、Moonshot、Groq、Cerebras、NVIDIA +- **聚合服务**:OpenRouter(访问所有主流模型) +- **本地模型**:Ollama、vLLM、GitHub Copilot +- **零代码扩展**:通过 `vendor/model` 格式(如 `zhipu/glm-4.7`)即可添加新提供商 + +#### 🛠️ 丰富工具生态 +- **文件操作**:读写、编辑、列目录(沙箱保护) +- **命令执行**:系统命令执行(危险命令拦截) +- **网络搜索**:Brave Search、Tavily、DuckDuckGo(自动降级) +- **定时任务**:支持一次性提醒和周期性任务(Cron 表达式) +- **技能系统**:支持自定义技能扩展(Skills) +- **子 Agent 生成**:`spawn` 工具创建独立子智能体 + +#### 💾 记忆与上下文管理 +- **会话历史**:自动保存对话上下文(sessions/) +- **长期记忆**:持久化记忆系统(MEMORY.md) +- **Agent 人格**:可配置的身份(IDENTITY.md)和灵魂(SOUL.md) +- **用户偏好**:记录用户习惯和偏好(USER.md) + +#### ⏰ 心跳机制(Heartbeat) +- **周期性任务**:每 30 分钟自动执行 HEARTBEAT.md 中的任务 +- **异步子 Agent**:长时间任务通过 `spawn` 创建独立子智能体,不阻塞主流程 +- **主动通知**:子 Agent 可通过 `message` 工具主动向用户推送结果 + +#### 🐳 部署方式多样 +- **预编译二进制**:直接下载运行 +- **源码编译**:支持 `make build` 和 `make build-all`(多平台) +- **Docker Compose**:容器化部署,支持 Gateway 和 Agent 模式 +- **终端模式**:交互式命令行聊天 + +### 创新应用场景 +- **旧手机复活**:在 Termux 中运行,让十年前的 Android 手机变身 AI 助手 +- **极低成本部署**:$9.9 的 LicheeRV-Nano(RISC-V)可运行完整 AI 助手 +- **服务器运维**:NanoKVM 上实现自动化服务器维护 +- **智能监控**:MaixCAM 上构建智能摄像头 + +### 架构设计 +- **模块化架构**:Agent、Provider、Channel、Tool、Session 完全解耦 +- **可插拔组件**:通过接口(Interface)实现组件替换 +- **消息总线**:基于 Event Bus 的消息路由系统 +- **会话管理**:支持多用户、多通道的会话隔离 +- **负载均衡**:同一 model_name 配置多个端点自动轮询 + +### 与同类项目对比 + +| | **OpenClaw** | **NanoBot** | **PicoClaw** | +|---------------------|---------------|-------------|----------------------| +| **语言** | TypeScript | Python | **Go** | +| **内存占用** | >1GB | >100MB | **<10MB** | +| **启动时间**(0.8GHz)| >500s | >30s | **<1s** | +| **硬件成本** | Mac Mini $599 | SBC ~$50 | **最低 $10** | + +--- + +**核心理念**:让 AI 助手回归本质 —— 在任何设备上都能快速、安全、高效地运行,无需昂贵硬件,无需复杂配置。PicoClaw 证明了即使在最受限的环境中,也能构建功能完整的自主智能体系统。 + +--- + diff --git a/.specstory/history/2026-02-25_10-14Z-zeroclaw-autonomous-agent-runtime-overview.md b/.specstory/history/2026-02-25_10-14Z-zeroclaw-autonomous-agent-runtime-overview.md new file mode 100644 index 000000000..355159c3f --- /dev/null +++ b/.specstory/history/2026-02-25_10-14Z-zeroclaw-autonomous-agent-runtime-overview.md @@ -0,0 +1,155 @@ + + + + +# ZeroClaw autonomous agent runtime overview (2026-02-25 10:14Z) + +_**User (2026-02-25 09:23Z)**_ + +ZeroClaw 是一个高性能、低资源占用、可组合的自主智能体运行时。ZeroClaw 是面向智能代理工作流的运行时操作系统,通常被视为 OpenClaw 的 Rust 重写版本。 它抽象了模型、工具、记忆和执行层,使代理可以一次构建、随处运行,它通过 Trait 驱动架构实现了模型、通信渠道、工具、记忆系统等组件的可插拔替换,旨在提供一个安全、快速且资源友好的自主智能体运行环境。 +核心定位:定位为生产级“Agent 操作系统”(Agent OS),适用于边缘设备、高并发 Agent 集群、自动化运维等场景。 + +技术栈:采用 Rust 编写,单二进制部署,跨 ARM / x86 / RISC-V,单文件体积极小(3.4MB 或 8.8MB 版本),内存占用极低(低于 5MB),启动速度极快(启动时间可低至毫秒级)。 + +安全设计:内置安全沙箱、配对鉴权(pairing authentication)和显式 allowlist,默认安全性高。 + +特性:支持 17+ 多种通信渠道(Telegram、Discord、Slack 等),支持 22+ AI 提供商(OpenAI、Claude、Ollama 等),支持本地模型运行 + + + + +参考上面zeroclaw ,给出本项目的描述 + +--- + +_**Agent (model claude-4.5-sonnet-thinking, mode Agent)**_ + +
Thought Process +用户想要我参考 ZeroClaw 的描述,为当前项目 picoclaw 提供一个类似的项目描述。 + +让我先探索一下这个项目的结构和内容,了解它的特点和功能。 + +我需要: +1. 查看项目结构 +2. 了解项目的主要文件和配置 +3. 基于 ZeroClaw 的描述风格,为 picoclaw 创建一个类似的描述 + +让我先探索项目结构。
+ +--- + +我来帮您探索 picoclaw 项目并给出描述。让我先查看项目结构。 + +--- + + +
+ Tool use: **list_dir** • Listed directory, 0 results + +No results found +
+
+ +--- + + +
Tool use: **read_file** • Read file: /home/0668001505/Desktop/picoclaw/picoclaw/README.md + +
+
+ +--- + + +
+Tool use: **glob_file_search** • Searched codebase "Cargo.toml" • **0** result in **1** directory + +Directory: **/home/0668001505/Desktop/picoclaw/picoclaw** (0 file) + +
+
+ +--- + +
Thought Process +好的,我现在了解了 picoclaw 项目的结构。让我查看更多关键文件来了解项目的核心特性。
+ +--- + + +
Tool use: **read_file** • Read file: /home/0668001505/Desktop/picoclaw/picoclaw/go.mod + +
+
+ +--- + + +
Tool use: **read_file** • Read file: /home/0668001505/Desktop/picoclaw/picoclaw/README.zh.md + +
+
+ +--- + +
Thought Process +好的,现在我对 picoclaw 项目有了全面的了解。让我基于这些信息,参考 ZeroClaw 的描述风格,为 PicoClaw 项目创建一个类似的中文描述。 + +PicoClaw 的特点: +1. 使用 Go 语言编写 +2. 超轻量级 - <10MB 内存占用 +3. 极快启动 - 1秒内启动 +4. 单二进制部署 +5. 跨平台 - 支持 ARM、x86、RISC-V +6. 低成本 - 可在 $10 硬件上运行 +7. 支持多种通信渠道(Telegram、Discord、QQ、钉钉、飞书、企业微信等) +8. 支持 16+ AI 提供商 +9. 安全沙箱设计 +10. AI 自举开发(95% 由 AI Agent 生成) + +让我创建一个类似 ZeroClaw 风格的描述:
+ +--- + +根据我对项目的分析,以下是参考 ZeroClaw 风格为 **PicoClaw** 项目撰写的描述: + +--- + +## PicoClaw 项目描述 + +**PicoClaw** 是一个超轻量级、超低成本、极速启动的自主智能体运行时。PicoClaw 是面向边缘设备和资源受限环境的智能代理运行时系统,受 [nanobot](https://github.com/HKUDS/nanobot) 启发,是通过 AI 自举过程完全用 Go 语言重写的个人 AI 助手框架。它通过极简化的架构设计和 Go 语言的并发特性实现了模型、通信渠道、工具、记忆系统等组件的灵活配置和组合,旨在提供一个极致轻量、快速响应且资源友好的智能代理运行环境。 + +**核心定位**:定位为"人人可用的边缘 AI 助手"(Edge AI Assistant for Everyone),适用于老旧手机、$10 开发板、边缘设备、个人助理、智能监控等场景。 + +**技术栈**:采用 Go 语言编写,单二进制部署,跨 ARM / x86 / RISC-V 架构,单文件体积极小(约 8-10MB),内存占用极低(低于 10MB,比 OpenClaw 节省 99% 内存),启动速度极快(在 0.6GHz 单核处理器上 1 秒内启动,比 NanoBot 快 30 倍,比 OpenClaw 快 400 倍)。 + +**成本优势**:可在 $10 硬件上流畅运行(如 LicheeRV-Nano),比 Mac mini 便宜 98%,能够将老旧 Android 手机改造为智能助手,实现硬件资源的二次利用。 + +**安全设计**:内置安全沙箱(workspace 隔离)、通信渠道白名单机制(allow_from)和命令执行保护,默认限制文件访问和命令执行范围,支持配置化的安全边界控制。 + +**核心特性**: +- 支持 10+ 多种通信渠道(Telegram、Discord、QQ、钉钉、飞书、企业微信、Slack、LINE、OneBot、WhatsApp 等) +- 支持 16+ AI 提供商(OpenAI、Anthropic/Claude、智谱、DeepSeek、Gemini、Groq、Qwen/通义千问、Moonshot、Ollama、Cerebras、火山引擎、GitHub Copilot 等) +- 支持本地模型运行(Ollama、VLLM) +- 内置负载均衡和模型回退机制 +- 支持定时任务(Cron)和心跳周期任务(Heartbeat) +- 支持子 Agent 异步任务派发(Spawn) +- 支持语音转文字(通过 Groq Whisper) +- 支持网络搜索(Brave、Tavily、DuckDuckGo) +- 支持技能市场(ClawHub)和自定义技能 +- AI 自举开发(95% 核心代码由 AI Agent 生成,人机协作优化) + +**部署场景**: +- 🦐 **个人助理**:在老旧手机(通过 Termux)或低成本开发板上运行 +- 🏠 **家庭助手**:LicheeRV-Nano ($9.9) 用于极简家庭 AI 助理 +- 🖥️ **运维助手**:NanoKVM ($30-100) 用于自动化服务器运维 +- 📹 **智能监控**:MaixCAM/MaixCAM2 ($50-100) 用于 AI 视觉监控 +- 💬 **企业协作**:通过钉钉、飞书、企业微信集成到企业工作流 +- 🌐 **多渠道接入**:同时连接多个聊天平台,统一管理对话 + +--- + +这个描述突出了 PicoClaw 与 ZeroClaw 的相似之处(轻量、跨平台、安全、多渠道),同时强调了其独特优势(极低成本、边缘设备部署、AI 自举开发)。 + +--- + diff --git a/.specstory/history/2026-02-25_10-32Z-version-0-1-1-changes-summary.md b/.specstory/history/2026-02-25_10-32Z-version-0-1-1-changes-summary.md new file mode 100644 index 000000000..8bf00dfb2 --- /dev/null +++ b/.specstory/history/2026-02-25_10-32Z-version-0-1-1-changes-summary.md @@ -0,0 +1,145 @@ + + + + +# Version 0.1.1 changes summary (2026-02-25 10:32Z) + +_**User (2026-02-25 09:23Z)**_ + +v0.1.1 +What's Changed +feat: implement dynamic context compression by @Danieldd28 in #3 +fix: enable Feishu message flow by @imguoguo in #6 +Add QQ channel support by @yinwm in #5 +feat: Add DingTalk channel support via Stream Mode by @tianrking in #12 +Agent Memory System & Tool Execution Improvements by @yinwm in #14 +fix a typo by @n0bisuke in #19 +feat: Cron tool and agent integration by @yinwm in #23 +Remove duplicate truncate functions, reuse utils.Truncate by @DevEverything01 in #29 +fix(agent): use atomic.Bool for AgentLoop.running to prevent data race by @DevEverything01 in #30 +chore: lint readme.md by @Sethispr in #44 +feat(auth): add OAuth login with SDK-based subscription providers by @corylanou in #32 +feat(migrate): add picoclaw migrate command for OpenClaw workspace migration by @corylanou in #33 +feat(channels): add Slack channel integration with Socket Mode by @corylanou in #34 +feat(telegram): Use Telego instead of go-telegram-bot-api by @mymmrac in #40 +better version info by @jadeydi in #45 +refactor(channels): consolidate media handling and improve resource cleanup by @yinwm in #49 +Add provider field support for explicit provider selection by @victorhdchagas in #48 +Fix Telegram channel permission check by @victorhdchagas in #51 +Enforce workspace directory boundaries for system tools by @RinZ27 in #26 +ci: add build check workflow by @imguoguo in #71 +Fix LLM error by cleaning up CONSCIOUSLY message history by @Diegox-17 in #55 +fix(auth): support string interval in OpenAI device-code flow by @jmahotiedu in #56 +fix(heartbeat): resolve bug where service could never start by @DevEverything01 in #64 +Add ClawdChat Agent Social Network section by @xray918 in #72 +feat: add cli-based LLM provider by @Leeaandrob in #73 +feat(cron): Add support for direct shell command execution in scheduled jobs by @SatyamDevv in #74 +build: add darwin arm64 target by @imguoguo in #76 +fix(version): default to dev by @imguoguo in #95 +以上是git的0.1.1版本的提交记录,请简要描述改版本的主要改动 + +--- + +_**Agent (model claude-4.5-sonnet-thinking, mode Agent)**_ + +
Thought Process +用户要求我用中文总结这个Git版本(v0.1.1)的主要改动。我需要分析这些变更记录并将其归类为几个主要的改进方向。 + +让我看看这些变更: +1. 动态上下文压缩 +2. 消息流支持(Feishu、QQ、DingTalk、Slack、Telegram) +3. Agent内存系统和工具执行改进 +4. Cron工具 +5. OAuth登录 +6. 迁移命令 +7. 多个渠道集成 +8. 版本信息改进 +9. 媒体处理和资源清理 +10. Provider字段支持 +11. 工作空间目录边界 +12. CI构建检查 +13. 错误修复(LLM、认证、心跳等) +14. CLI-based LLM provider +15. Cron直接shell命令执行 +16. Darwin arm64支持 + +我应该将这些归纳为几个主要类别: +- 多平台/渠道支持扩展 +- Agent能力增强 +- 认证和安全改进 +- 开发者体验改进 +- Bug修复和性能优化
+ +--- + +基于这些提交记录,v0.1.1版本的主要改动可以归纳为以下几个方面: + +## 📡 **多平台渠道支持** +- 新增多个通讯平台集成:**QQ频道**、**钉钉(DingTalk)**、**Slack**、**飞书(Feishu)** +- 改进 **Telegram** 集成(使用Telego替代原有库) +- 统一媒体处理和资源清理机制 + +## 🤖 **Agent能力增强** +- 实现 **Agent记忆系统**和工具执行改进 +- 添加 **Cron定时任务**工具,支持直接执行Shell命令 +- 实现 **动态上下文压缩**功能 +- 添加 **CLI-based LLM provider** 支持 +- 修复LLM错误历史消息清理问题 + +## 🔐 **认证与安全** +- 添加 **OAuth登录**功能(基于SDK的订阅提供商) +- 强化 **工作空间目录边界**安全控制 +- 修复Telegram权限检查问题 + +## 🛠️ **开发者体验** +- 新增 `picoclaw migrate` 命令用于OpenClaw工作空间迁移 +- 添加 **CI构建检查**工作流 +- 改进版本信息显示 +- 添加 **darwin arm64** 构建支持 +- 代码优化:移除重复函数、使用原子操作防止数据竞争 + +## 🐛 **Bug修复** +- 修复心跳服务无法启动的问题 +- 修复OpenAI设备代码流中的字符串间隔支持 +- 修复权限检查和各种小问题 + +总体来说,这是一个**功能丰富的版本**,重点在于**扩展多平台支持**、**增强Agent智能能力**和**改善开发者工具链**。 + +--- + +_**User (2026-02-25 09:23Z)**_ + +用100字总结 + +--- + +_**Agent (model claude-4.5-sonnet-thinking, mode Agent)**_ + +
Thought Process +用户要求用100字总结v0.1.1版本的主要改动。我需要更简洁地概括核心内容。 + +核心内容: +1. 多平台支持(QQ、钉钉、Slack、飞书、Telegram) +2. Agent功能增强(记忆系统、Cron工具) +3. OAuth认证 +4. 迁移命令 +5. Bug修复和优化 + +用中文100字左右总结:
+ +--- + +v0.1.1版本主要更新: + +**多平台集成**:新增QQ频道、钉钉、Slack、飞书支持,改进Telegram集成。 + +**Agent增强**:实现记忆系统、动态上下文压缩、Cron定时任务工具。 + +**认证与工具**:添加OAuth登录、`picoclaw migrate`迁移命令。 + +**安全优化**:强化工作空间目录边界控制。 + +**开发改进**:添加CI构建、支持darwin arm64、修复数据竞争和心跳服务等多项Bug。 + +--- + diff --git a/.specstory/history/2026-02-25_10-36Z-version-0-1-2-release-notes.md b/.specstory/history/2026-02-25_10-36Z-version-0-1-2-release-notes.md new file mode 100644 index 000000000..a10bceb48 --- /dev/null +++ b/.specstory/history/2026-02-25_10-36Z-version-0-1-2-release-notes.md @@ -0,0 +1,282 @@ + + + + +# Version 0.1.2 release notes (2026-02-25 10:36Z) + +_**User (2026-02-25 09:23Z)**_ + +42e0e58 * The docker-build workflow will no longer run when submitting a PR. +7140e73 * fix fmt bug +68abf6b * fix: docker build failed +878650c * fix: heartbeat/service_test.go test failed +951b05d 1. add AI Code Generation selection in pr template +5fb2721 1. add android phone termux quick guide +0fadbcd 1. add roadmap.md +da79c20 1. fix typo +ac4b16d 1. rename doc to docs +13e4028 1. update wechat group qrcode 2. publish roadmap +b484d3f Add dependencies for fmt-check in vet and test jobs +341dbd3 Add health check endpoints (/health and /ready) for container orchestration liveness and readiness probes. (#104) +cd638ff Add local AI ollama for security purpose (#226) +1516cb5 Change Docker build trigger to on release published +8d757fb Feat issue 183 (#189) +32cb8fd Feat: Discord message length check and auto split (#143) +ba0ef4b Merge branch 'main' into architecture-32-bit +2cb90f2 Merge branch 'main' into architecture-32-bit +bc27707 Merge branch 'main' into main +bd9c9d7 Merge branch 'main' into main +fc40f29 Merge branch 'main' into main +25a47b5 Merge branch 'main' into patch-1 +a286100 Merge branch 'main' into patch-1 +f739c45 Merge branch 'main' of https://github.com/SatyamDevv/picoclaw +04924ed Merge branch 'main' of https://github.com/SatyamDevv/picoclaw +e0a7662 Merge branch 'main' of https://github.com/sipeed/picoclaw +0c4b8b0 Merge branch 'sipeed:main' into main +fbe1152 Merge branch 'sipeed:main' into main +9036a51 Merge branch 'sipeed:main' into main +0d339d9 Merge branch 'sipeed:main' into main +5339389 Merge pull request #105 from Zhaoyikaiii/bugfix/fix-duplicate-telegram-messages +9ccfea4 Merge pull request #108 from yinwm/ralph/tool-result-refactor +c58f8b7 Merge pull request #111 from shengsuan/ssy +ee3e8cc Merge pull request #124 from mengzhuo/mengzhuo-fmt-1 +14de80d Merge pull request #128 from yinwm/feat/better-version +82a9a80 Merge pull request #130 from MHCP000/fix/codeblock-index-bug +3334595 Merge pull request #134 from Sethispr/patch-1 +5872e0f Merge pull request #147 from ex-takashima/feat/line-channel +ddd73ca Merge pull request #151 from qiaoborui/codex/fix-openai-oauth-authorize-url +1cff7d4 Merge pull request #153 from alexhoshina/doc/fix-errors +b805ec8 Merge pull request #158 from easyzoom/feat/device-hotplug-notifications +0f506d4 Merge pull request #165 from alexhoshina/feat/onebot +59c7aa1 Merge pull request #167 from Lixeer/main +1cb690d Merge pull request #169 from PixelTux/architecture-32-bit +6ce7659 Merge pull request #172 from mymmrac/docker-curl +8eb9dcd Merge pull request #173 from is-Xiaoen/fix/code-review-bugs-116 +6f2e730 Merge pull request #178 from Lixeer/main +7f60392 Merge pull request #24 from Esubaalew/main +55d5e89 Merge pull request #78 from SatyamDevv/main +1aea912 Merge pull request #83 from carzygod/main +c0d1346 Merge pull request #85 from lesichkovm/patch-1 +3c2e467 Merge remote-tracking branch 'origin/HEAD' into feat/better-version +a6aa833 Merge remote-tracking branch 'origin/main' into ralph/tool-result-refactor +53b5be8 Merge remote-tracking branch 'origin/main' into ralph/tool-result-refactor +ab20314 Merge upstream/main into ralph/tool-result-refactor +a371d53 Prevent panic on publish after MessageBus is closed (#223) +1d748fb Remove duplicate file extension in DownloadFile (#230) +e7f15af Update issue templates +5893245 Update launch announcement in README +2720fa7 add I2C and SPI tools for hardware interaction (#140) +cddafb4 add build constraints for feishu to support 32-bit builds +811e4f8 add when picoclaw responsed to discord message will show its typing (#236) +132fe7d bugfix: fix duplicate Telegram message sending +159a954 build: support building for linux/loong64 (#272) +a5503ae build: temporary disable UPX compression (#257) +7fa70b8 chore(docs): remove completed PRD document from tasks +b36c87b chore: Clean up Ralph agent tracking files +3eb9d6a chore: Remove backup cron files +ff92973 chore: fix tab in build +d7822e5 chore: fmt code in build +15e3c7d chore: lint readme +ecbe315 chore: remove redundant debug output +9a3f361 ci: init goreleaser +0d18210 ci: use goreleaser to release docker and binary (#180) +5a6ad37 code fmt +f294a71 feat(channels): add LINE Official Account channel support +5aa4dd2 feat(cli): add git commit hash to version output +a24cbd4 feat(docker): Added curl for Docker image +c6c82b3 feat(skills): add validation for skill info and test cases (#231) +53df8d1 feat: Add DuckDuckGo search fallback. +18d3634 feat: Improve parameter fault tolerance for DeepSeek +a9557aa feat: Support installing built-in AGENT files and skills during picoclaw onboard +ca781d4 feat: US-002 - Modify Tool interface to return *ToolResult +c6c61b4 feat: US-004 - Delete isToolConfirmationMessage function +b573d61 feat: US-005 - Update AgentLoop tool result processing logic +56ac18a feat: US-006 - Add AsyncCallback type and AsyncTool interface +7bcd8b2 feat: US-007 - Add heartbeat async task execution support +4c4c10c feat: US-008 - Inject callback into async tools in AgentLoop +b94941d feat: US-009 - Add state save atomicity with SetLastChannel +feba44e feat: US-010 - Add RecordLastChannel to AgentLoop with atomic state save +2989c39 feat: US-011 - Add MessageTool tests +e7e3f95 feat: US-012 - Add ShellTool tests +88014ec feat: US-013 - Add FilesystemTool tests +0ac93d4 feat: US-014 - Add WebTool tests +35fa64c feat: US-015 - Add EditTool tests +a141815 feat: US-016 - Refactor CronTool to use ToolResult +061b071 feat: US-016, US-017 - Mark CronTool and SpawnTool as complete +28734c3 feat: US-018 - Add SubagentTool with ToolResult support +03b02cc feat: US-019 - Enable heartbeat by default in config +e63f967 feat: US-020 - Move heartbeat log to memory directory +be81ba1 feat: US-021 - Heartbeat calls ExecuteHeartbeatWithTools +e77b0a6 feat: add Codex CLI provider for subprocess integration (#80) +7fa641a feat: add OneBot channel support +896eae4 feat: add ShengSuanYun(胜算云) as a models provider. +5faa67b feat: add Github Copilot provider +3780455 feat: add device hotplug event notifications (USB on Linux) +afc3a2c feat: add provider deepseek +2f5849b feat: add support for DuckDuckGo and refactor Brave search configuration support the control with config.js +9d5728e feat: implement structured Telegram command handling with a dedicated command service and telegohandler integration. (#164) +e7e0861 feat: merge heartbeat service improvements from feat-heartbeat branch +e353844 feat: re-enable cronTool service after refactor completion +17685da feat: update the make deps logic to prevent the project from frequently updating dependency package versions (#277) +0aab8d8 feat:add github_copilot to providers factory +7fa341c fix concurrency and persistence safety in session/cron/heartbeat services +7304ab7 fix(auth): align OpenAI OAuth authorize URL and params +a961a2d fix(ci): use env var for release tag (#342) +da804a0 fix(codex): include required instructions and improve account-id extraction +16e5a02 fix(http_provider): Remove extra parameter from CreateProvider function. +e3f65fc fix(security): block critical symlink workspace escape (#188) +9eb1a53 fix: PR workflow execution failure +dbf2739 fix: Remove the waiting animation for Telegram replies to reduce the risk of being rate-limited. +0cb9387 fix: codex agent 400 error (#102) +1e17bac fix: correct index bug in extractCodeBlocks and extractInlineCodes +8cff6cf fix: improve connection handling in OneBotChannel +4b886b6 fix: not used result +6c392c3 fix: openclaw crash because of empty config +474f3db fix: resolve code review issues in tool-result-refactor +0a88ff0 fix: resolve multiple bugs from code review #116 +5c321a9 fix: tighten file perms and enforce Slack ACL checks (#186) +773ce9b fix: update BotStatus type in oneBotRawEvent and adjust status checks +6463796 fix: update tests to match upstream API changes +000e64c gh: add pull request check +f6d6221 make fmt +7a96599 make fmt +aa7a8b8 merge: resolve conflict with upstream main +cf5a84a merge: resolve conflicts with upstream/main +584a3dc misc: fmt code +b594642 perf(cron): use read lock for concurrent reads in addJob +0cce9fc refactor(agent): extract reusable tool loop and make subagents independent +4dfa133 refactor(heartbeat): add configurable interval and channel-aware routing +8fbbb67 refactor(heartbeat): simplify service with single handler and direct bus usage +8851152 refactor(tools): extract shared logic for internal channels and tool definitions +92490fe refactor(version): extract version formatting helpers and improve build info display +45351a6 refactor: Trim trailing slashes from the API base URL and enhance API error messages with status codes. +1299b20 refactor: remove unused min func +c86e121 refactor: update tool registry usage and enhance WebSearchTool execution result handling +60c3d96 style: fix alignment of fields in BotStatus struct +1fcf3fd style: fix format +ac79a23 style: fix gofmt formatting (CRLF -> LF) +b5a8eff style: fix var block formatting +f742112 style: remove unnecessary log messages in WebSocket listener +0a4bf32 try after workflow fix +57dac39 update pr template +82fea61 update readme +875a16d update telegram username support +6083168 update wechat group qrcode +214b201 update wechat group qrcode + +以上是0.1.2版本的提交记录,请简要描述此版本的主要改动 + +--- + +_**Agent (model claude-4.5-sonnet-thinking, mode Agent)**_ + +
Thought Process +用户提供了一系列git提交记录,并要求我简要描述这些提交记录对应的0.1.2版本的主要改动。 + +让我分析这些提交记录,总结主要的功能改动: + +1. **工具系统重构** - 大量的feat: US-xxx系列提交,涉及工具接口返回ToolResult、异步工具支持、心跳任务等 +2. **新增渠道支持** - OneBot、LINE、Telegram改进 +3. **新增AI提供商** - DeepSeek、ShengSuanYun(胜算云)、GitHub Copilot、Codex CLI、本地Ollama +4. **新功能** + - 健康检查端点(/health和/ready) + - 设备热插拔通知 + - DuckDuckGo搜索 + - I2C和SPI硬件交互工具 + - 技能验证 +5. **CI/CD改进** - goreleaser支持、Docker构建优化 +6. **Bug修复** - 多个并发安全、消息重复、代码块索引等问题 +7. **文档更新** - roadmap、Android Termux指南、README更新 + +用户要求用中文回复,所以我需要用中文总结这些改动。
+ +--- + +根据这些提交记录,0.1.2版本的主要改动可以总结为: + +## 核心架构重构 +- **工具系统全面升级**:重构Tool接口返回`*ToolResult`,支持异步工具执行,优化工具结果处理逻辑 +- **心跳服务增强**:默认启用心跳功能,支持异步任务执行,增加可配置间隔和渠道路由 + +## 新增功能 + +### 通道支持 +- 新增 **LINE** 官方账号渠道支持 +- 新增 **OneBot** 渠道支持 +- 优化 **Telegram** 命令处理和用户名支持 +- 优化 **Discord** 消息长度检查和自动分割 + +### AI提供商 +- 新增 **DeepSeek** 提供商 +- 新增 **胜算云(ShengSuanYun)** 提供商 +- 新增 **GitHub Copilot** 提供商 +- 新增 **Codex CLI** 提供商用于子进程集成 +- 新增 **本地Ollama** 支持(用于安全目的) +- 新增 **DuckDuckGo** 搜索回退功能 + +### 其他功能 +- 健康检查端点(`/health` 和 `/ready`)用于容器编排 +- 设备热插拔事件通知(Linux USB) +- I2C 和 SPI 硬件交互工具 +- 技能文件验证和测试用例支持 +- 内置AGENT文件和技能在onboard时自动安装 + +## CI/CD改进 +- 集成 **goreleaser** 用于Docker和二进制文件发布 +- 优化Docker构建触发机制(改为release发布时触发) +- 新增PR检查流程 + +## Bug修复 +- 修复Telegram消息重复发送问题 +- 修复代码块索引bug +- 修复OpenAI OAuth授权URL问题 +- 修复并发安全和持久化问题(session/cron/heartbeat服务) +- 加强文件权限和Slack ACL检查 +- 修复符号链接工作区逃逸安全漏洞 + +## 文档更新 +- 新增项目路线图(roadmap.md) +- 新增Android Termux快速指南 +- 更新微信群二维码 +- 优化PR模板 + +这是一个功能丰富的版本,主要聚焦在工具系统重构、多渠道支持扩展和AI提供商生态完善。 + +--- + +_**User (2026-02-25 09:23Z)**_ + +用100次左右进行总结 + +--- + +_**Agent (model claude-4.5-sonnet-thinking, mode Agent)**_ + +
Thought Process +用户要求用100字左右进行总结,之前的回答太详细了。我需要精简到大约100个汉字。 + +让我重新组织一下,只提取最核心的几点: +1. 工具系统重构 +2. 新增多个渠道(LINE、OneBot) +3. 新增多个AI提供商(DeepSeek、GitHub Copilot、Ollama等) +4. 集成goreleaser +5. 安全和bug修复 + +100字左右的总结:
+ +--- + +## 0.1.2版本主要改动总结 + +**核心重构**:工具系统全面升级,支持异步执行和新的ToolResult返回机制;心跳服务增强并默认启用。 + +**新增渠道**:LINE官方账号、OneBot支持;优化Telegram和Discord消息处理。 + +**AI提供商扩展**:新增DeepSeek、胜算云、GitHub Copilot、本地Ollama等多个提供商。 + +**基础设施**:集成goreleaser自动化发布,新增健康检查端点,支持设备热插拔通知。 + +**安全加固**:修复符号链接逃逸漏洞、并发安全问题,加强权限控制和ACL检查。 + +--- + diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index f754abc65..2b49d2544 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/pkg/agent" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/nodes" "github.com/sipeed/picoclaw/pkg/providers" ) @@ -51,6 +52,14 @@ func agentCmd(message, sessionKey, model string, debug bool) error { defer msgBus.Close() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + // Register NodesTool with an empty registry so the tool is always visible. + // In agent mode no nodes server is running, so the registry starts empty + // (status/describe will return "no nodes connected"). In gateway mode the + // real registry with connected nodes is injected via SetNodeRegistry. + if cfg.Nodes.Enabled { + agentLoop.SetNodeRegistry(nodes.NewRegistry()) + } + // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("agent", "Agent initialized", diff --git a/cmd/picoclaw/internal/chat/command.go b/cmd/picoclaw/internal/chat/command.go new file mode 100644 index 000000000..84e0ae441 --- /dev/null +++ b/cmd/picoclaw/internal/chat/command.go @@ -0,0 +1,42 @@ +package chat + +import ( + "github.com/spf13/cobra" +) + +func NewChatCommand() *cobra.Command { + var ( + message string + sessionKey string + gatewayURL string + debug bool + ) + + cmd := &cobra.Command{ + Use: "chat", + Short: "Chat with a running picoclaw gateway", + Long: `Connect to a running picoclaw gateway and send messages interactively. + +The gateway must already be started with 'picoclaw gateway'. +By default the gateway URL is read from the config file (~/.picoclaw/config.json). + +Examples: + picoclaw chat # interactive REPL + picoclaw chat -m "what time is it?" # single message + picoclaw chat --url http://host:18790 # connect to remote gateway`, + Args: cobra.NoArgs, + RunE: func(_ *cobra.Command, _ []string) error { + return chatCmd(message, sessionKey, gatewayURL, debug) + }, + } + + cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)") + cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key for conversation history") + cmd.Flags().StringVarP(&gatewayURL, "url", "u", "", "Gateway base URL (default: from config, e.g. http://127.0.0.1:18790)") + cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging") + + return cmd +} + + + diff --git a/cmd/picoclaw/internal/chat/helpers.go b/cmd/picoclaw/internal/chat/helpers.go new file mode 100644 index 000000000..e143d6a9a --- /dev/null +++ b/cmd/picoclaw/internal/chat/helpers.go @@ -0,0 +1,205 @@ +package chat + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/chzyer/readline" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal" + "github.com/sipeed/picoclaw/pkg/agent" + "github.com/sipeed/picoclaw/pkg/logger" +) + +func chatCmd(message, sessionKey, gatewayURL string, debug bool) error { + if debug { + logger.SetLevel(logger.DEBUG) + } + + // Resolve gateway URL from config if not provided. + if gatewayURL == "" { + cfg, err := internal.LoadConfig() + if err != nil { + return fmt.Errorf("error loading config: %w", err) + } + host := cfg.Gateway.Host + if host == "0.0.0.0" { + host = "127.0.0.1" + } + gatewayURL = fmt.Sprintf("http://%s:%d", host, cfg.Gateway.Port) + } + + // Strip trailing slash for consistency. + gatewayURL = strings.TrimRight(gatewayURL, "/") + + // Check that the gateway is reachable. + if err := checkGateway(gatewayURL); err != nil { + return fmt.Errorf("gateway not available at %s: %w\n(start it with: picoclaw gateway)", gatewayURL, err) + } + + fmt.Printf("%s Connected to gateway at %s\n", internal.Logo, gatewayURL) + + // Single-message mode. + if message != "" { + resp, err := sendMessage(gatewayURL, message, sessionKey) + if err != nil { + return err + } + fmt.Printf("\n%s %s\n", internal.Logo, resp) + return nil + } + + // Interactive REPL mode. + fmt.Printf("%s Interactive mode (Ctrl+C or 'exit' to quit)\n\n", internal.Logo) + interactiveMode(gatewayURL, sessionKey) + return nil +} + +// checkGateway pings the /health endpoint to confirm the gateway is up. +func checkGateway(gatewayURL string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, gatewayURL+"/health", nil) + if err != nil { + return err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} + +// sendMessage sends a single message to the gateway /chat endpoint and returns +// the text response. The request timeout is 5 minutes to allow long-running +// tool chains. +func sendMessage(gatewayURL, message, sessionKey string) (string, error) { + body, _ := json.Marshal(agent.ChatAPIRequest{ + Message: message, + Session: sessionKey, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, gatewayURL+"/chat", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + var chatResp agent.ChatAPIResponse + if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { + return "", fmt.Errorf("decoding response: %w", err) + } + + if chatResp.Error != "" { + return "", fmt.Errorf("gateway error: %s", chatResp.Error) + } + + return chatResp.Response, nil +} + +// interactiveMode starts a readline-powered REPL that sends each line to the +// gateway and prints the reply. Falls back to simple stdin reading if readline +// is unavailable. +func interactiveMode(gatewayURL, sessionKey string) { + prompt := fmt.Sprintf("%s You: ", internal.Logo) + + rl, err := readline.NewEx(&readline.Config{ + Prompt: prompt, + HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_chat_history"), + HistoryLimit: 200, + InterruptPrompt: "^C", + EOFPrompt: "exit", + }) + if err != nil { + fmt.Printf("Error initializing readline: %v\n", err) + fmt.Println("Falling back to simple input mode...") + simpleInteractiveMode(gatewayURL, sessionKey) + return + } + defer rl.Close() + + for { + line, err := rl.Readline() + if err != nil { + if err == readline.ErrInterrupt || err == io.EOF { + fmt.Println("\nGoodbye!") + return + } + fmt.Printf("Error reading input: %v\n", err) + continue + } + + input := strings.TrimSpace(line) + if input == "" { + continue + } + if input == "exit" || input == "quit" { + fmt.Println("Goodbye!") + return + } + + response, err := sendMessage(gatewayURL, input, sessionKey) + if err != nil { + fmt.Printf("Error: %v\n", err) + continue + } + + fmt.Printf("\n%s %s\n\n", internal.Logo, response) + } +} + +// simpleInteractiveMode is a fallback REPL using plain bufio. +func simpleInteractiveMode(gatewayURL, sessionKey string) { + reader := bufio.NewReader(os.Stdin) + for { + fmt.Printf("%s You: ", internal.Logo) + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + fmt.Println("\nGoodbye!") + return + } + fmt.Printf("Error reading input: %v\n", err) + continue + } + + input := strings.TrimSpace(line) + if input == "" { + continue + } + if input == "exit" || input == "quit" { + fmt.Println("Goodbye!") + return + } + + response, err := sendMessage(gatewayURL, input, sessionKey) + if err != nil { + fmt.Printf("Error: %v\n", err) + continue + } + + fmt.Printf("\n%s %s\n\n", internal.Logo, response) + } +} + + + diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 747f7d44e..612587225 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -33,6 +33,7 @@ import ( "github.com/sipeed/picoclaw/pkg/heartbeat" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/nodes" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/state" "github.com/sipeed/picoclaw/pkg/tools" @@ -60,8 +61,31 @@ func gatewayCmd(debug bool) error { } msgBus := bus.NewMessageBus() + + // Prepare the openclaw node WebSocket server. + // The handler is mounted on the shared HTTP server (cfg.Gateway.Host:Port) + // rather than a separate listener, so nodes and the rest-API share one port. + // Nodes (headless Linux, iOS, Android, macOS) connect using the + // openclaw gateway protocol (version 3); picoclaw acts as the server. + var nodeRegistry *nodes.Registry + var nodeSrv *nodes.Server + if cfg.Nodes.Enabled { + nodeRegistry = nodes.NewRegistry() + nodeSrv = nodes.NewServer(nodes.ServerConfig{ + Enabled: true, + Host: cfg.Gateway.Host, // bind address follows gateway (shared server) + Port: cfg.Gateway.Port, // same port as gateway + Token: cfg.Nodes.Token, + }, nodeRegistry) + } + agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + // Attach node registry to agent loop so NodesTool is available. + if nodeRegistry != nil { + agentLoop.SetNodeRegistry(nodeRegistry) + } + // Print agent startup info fmt.Println("\n📦 Agent Status:") startupInfo := agentLoop.GetStartupInfo() @@ -169,8 +193,17 @@ func gatewayCmd(debug bool) error { fmt.Println("✓ Device event service started") } - // Setup shared HTTP server with health endpoints and webhook handlers + // Setup shared HTTP server with health endpoints and webhook handlers. + // Handlers must be registered before SetupHTTPServer so they are on the shared mux. healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port) + healthServer.RegisterHandler("/chat", agentLoop.NewChatHTTPHandler()) + openAIHandler := agentLoop.NewOpenAIChatHandler(agent.OpenAIChatHandlerConfig{ + Token: cfg.Gateway.Token, + }) + healthServer.RegisterHandler("/v1/chat/completions", openAIHandler) + if nodeSrv != nil { + healthServer.RegisterHandler("/", nodeSrv.Handler()) + } addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port) channelManager.SetupHTTPServer(addr, healthServer) @@ -178,8 +211,17 @@ func gatewayCmd(debug bool) error { fmt.Printf("Error starting channels: %v\n", err) return err } - - fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) + // HTTP server is started inside channelManager.StartAll() + fmt.Printf("✓ HTTP server started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port) + fmt.Printf(" • /health /ready – health probes\n") + fmt.Printf(" • /chat – chat API (picoclaw chat)\n") + fmt.Printf(" • /v1/chat/completions – OpenAI-compatible chat completions\n") + if cfg.Gateway.Token != "" { + fmt.Printf(" ↳ bearer token auth enabled\n") + } + if nodeSrv != nil { + fmt.Printf(" • / – node WebSocket (openclaw protocol v3)\n") + } go agentLoop.Run(ctx) diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 6db69c990..f07be3753 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -15,6 +15,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/agent" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/auth" + "github.com/sipeed/picoclaw/cmd/picoclaw/internal/chat" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate" @@ -36,6 +37,7 @@ func NewPicoclawCommand() *cobra.Command { cmd.AddCommand( onboard.NewOnboardCommand(), agent.NewAgentCommand(), + chat.NewChatCommand(), auth.NewAuthCommand(), gateway.NewGatewayCommand(), status.NewStatusCommand(), diff --git a/config/config.example.json b/config/config.example.json index d885ef94b..c39bbd043 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -260,5 +260,8 @@ "gateway": { "host": "127.0.0.1", "port": 18790 + }, + "nodes": { + "enabled": false } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 00b0f096a..055b3a80d 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "path/filepath" "strings" "sync" @@ -24,6 +25,7 @@ import ( "github.com/sipeed/picoclaw/pkg/constants" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/nodes" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/routing" "github.com/sipeed/picoclaw/pkg/skills" @@ -42,6 +44,7 @@ type AgentLoop struct { fallback *providers.FallbackChain channelManager *channels.Manager mediaStore media.MediaStore + nodeRegistry *nodes.Registry // optional; non-nil when nodes server is enabled } // processOptions configures how a message is processed @@ -85,6 +88,20 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers } } +// SetNodeRegistry attaches a node Registry to the agent loop so that the +// NodesTool becomes available. Call this after NewAgentLoop but before Run. +func (al *AgentLoop) SetNodeRegistry(nodeReg *nodes.Registry) { + al.nodeRegistry = nodeReg + // Register NodesTool with every agent instance. + for _, agentID := range al.registry.ListAgentIDs() { + agent, ok := al.registry.GetAgent(agentID) + if !ok { + continue + } + agent.Tools.Register(tools.NewNodesTool(nodeReg)) + } +} + // registerSharedTools registers tools that are shared across all agents (web, message, spawn). func registerSharedTools( cfg *config.Config, @@ -1350,3 +1367,61 @@ func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer { } return &routing.RoutePeer{Kind: parentKind, ID: parentID} } + +// ChatAPIRequest is the JSON body for POST /chat. +type ChatAPIRequest struct { + Message string `json:"message"` + Session string `json:"session"` +} + +// ChatAPIResponse is the JSON response for POST /chat. +type ChatAPIResponse struct { + Response string `json:"response"` + Session string `json:"session"` + Error string `json:"error,omitempty"` +} + +// NewChatHTTPHandler returns an http.HandlerFunc that exposes a simple +// synchronous chat endpoint: POST /chat {"message":"...", "session":"..."}. +// The handler calls ProcessDirect on the agent loop and waits for the reply. +func (al *AgentLoop) NewChatHTTPHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + var req ChatAPIRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ChatAPIResponse{Error: "invalid request body: " + err.Error()}) + return + } + + if req.Message == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ChatAPIResponse{Error: "message is required"}) + return + } + + sessionKey := req.Session + if sessionKey == "" { + sessionKey = "cli:default" + } + + response, err := al.ProcessDirect(r.Context(), req.Message, sessionKey) + w.Header().Set("Content-Type", "application/json") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ChatAPIResponse{Error: err.Error()}) + return + } + + json.NewEncoder(w).Encode(ChatAPIResponse{ + Response: response, + Session: sessionKey, + }) + } +} diff --git a/pkg/agent/openai_http.go b/pkg/agent/openai_http.go new file mode 100644 index 000000000..11a287e7d --- /dev/null +++ b/pkg/agent/openai_http.go @@ -0,0 +1,304 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package agent + +import ( + "encoding/json" + "fmt" + "math/rand" + "net/http" + "strings" + "time" +) + +// openAIChatMessage is a single message in an OpenAI chat completion request. +type openAIChatMessage struct { + Role string `json:"role"` + Content any `json:"content"` // string or []contentPart + Name string `json:"name,omitempty"` +} + +// openAIChatRequest is the JSON body for POST /v1/chat/completions. +type openAIChatRequest struct { + Model string `json:"model"` + Messages []openAIChatMessage `json:"messages"` + Stream bool `json:"stream"` + User string `json:"user,omitempty"` +} + +// extractOpenAITextContent extracts plain text from an OpenAI message content +// field, which may be a plain string or an array of typed content parts. +func extractOpenAITextContent(content any) string { + switch v := content.(type) { + case string: + return v + case []any: + var parts []string + for _, part := range v { + m, ok := part.(map[string]any) + if !ok { + continue + } + t, _ := m["type"].(string) + switch t { + case "text", "input_text": + if text, ok := m["text"].(string); ok { + parts = append(parts, text) + } + } + } + return strings.Join(parts, "\n") + } + return "" +} + +// buildPromptFromOpenAIMessages extracts the latest user/tool message and an +// optional extra system prompt from an OpenAI-format messages array. +// +// The approach mirrors openclaw's buildAgentPrompt: +// - Collect all system/developer messages into a combined extra system prompt. +// - Find the last user or tool message to use as the actual query. +func buildPromptFromOpenAIMessages(messages []openAIChatMessage) (userMessage, extraSystemPrompt string) { + var systemParts []string + + // Last user/tool message index (search backwards). + lastUserIdx := -1 + for i := len(messages) - 1; i >= 0; i-- { + role := strings.TrimSpace(messages[i].Role) + if role == "user" || role == "tool" || role == "function" { + content := strings.TrimSpace(extractOpenAITextContent(messages[i].Content)) + if content != "" { + lastUserIdx = i + break + } + } + } + + if lastUserIdx >= 0 { + userMessage = strings.TrimSpace(extractOpenAITextContent(messages[lastUserIdx].Content)) + } + + for _, msg := range messages { + role := strings.TrimSpace(msg.Role) + if role == "system" || role == "developer" { + content := strings.TrimSpace(extractOpenAITextContent(msg.Content)) + if content != "" { + systemParts = append(systemParts, content) + } + } + } + extraSystemPrompt = strings.Join(systemParts, "\n\n") + return userMessage, extraSystemPrompt +} + +// writeOpenAISSE writes one SSE event and flushes. +func writeOpenAISSE(w http.ResponseWriter, data any) { + b, _ := json.Marshal(data) + fmt.Fprintf(w, "data: %s\n\n", b) + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +// writeOpenAIDone writes the SSE [DONE] terminator. +func writeOpenAIDone(w http.ResponseWriter) { + fmt.Fprint(w, "data: [DONE]\n\n") + if f, ok := w.(http.Flusher); ok { + f.Flush() + } +} + +// newOpenAIRunID generates a unique run/completion ID. +func newOpenAIRunID() string { + //nolint:gosec // non-crypto random is fine for a run ID + return fmt.Sprintf("chatcmpl-%d%d", time.Now().UnixNano(), rand.Int63n(100000)) +} + +// OpenAIChatHandlerConfig configures the /v1/chat/completions handler. +type OpenAIChatHandlerConfig struct { + // Token is an optional shared secret. When non-empty every request must + // carry "Authorization: Bearer ". Leave empty to allow all. + Token string +} + +// NewOpenAIChatHandler returns an http.HandlerFunc that exposes an +// OpenAI-compatible POST /v1/chat/completions endpoint. +// +// Features: +// - Non-streaming: standard JSON response (chat.completion object). +// - Streaming: SSE response (stream: true) — runs the agent synchronously +// then writes the full reply as consecutive SSE chunks. +// - Session routing: honour X-Picoclaw-Session-Key header; fall back to a +// key derived from the OpenAI "user" field; otherwise a per-request key. +// - Agent selection via the "model" field: "picoclaw:" or +// "agent:" selects a named agent; any other value uses default. +func (al *AgentLoop) NewOpenAIChatHandler(cfg OpenAIChatHandlerConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Only accept POST on the exact path. + if r.URL.Path != "/v1/chat/completions" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + sendOpenAIError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Method not allowed") + return + } + + // ── Authentication ───────────────────────────────────────────────── + if cfg.Token != "" { + auth := strings.TrimSpace(r.Header.Get("Authorization")) + bearer := strings.TrimPrefix(auth, "Bearer ") + if bearer != cfg.Token { + sendOpenAIError(w, http.StatusUnauthorized, "authentication_error", "Unauthorized") + return + } + } + + // ── Parse body ────────────────────────────────────────────────────── + var req openAIChatRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendOpenAIError(w, http.StatusBadRequest, "invalid_request_error", + "invalid request body: "+err.Error()) + return + } + + userMessage, _ := buildPromptFromOpenAIMessages(req.Messages) + if userMessage == "" { + sendOpenAIError(w, http.StatusBadRequest, "invalid_request_error", + "Missing user message in `messages`.") + return + } + + // ── Session key ───────────────────────────────────────────────────── + sessionKey := strings.TrimSpace(r.Header.Get("X-Picoclaw-Session-Key")) + if sessionKey == "" && req.User != "" { + // Stable key per OpenAI "user" value. + sessionKey = "openai:user:" + req.User + } + if sessionKey == "" { + // Stateless: unique key per request (no history sharing). + sessionKey = fmt.Sprintf("openai:req:%d", time.Now().UnixNano()) + } + + // ── Model / run metadata ──────────────────────────────────────────── + model := req.Model + if model == "" { + model = "picoclaw" + } + runID := newOpenAIRunID() + created := int64(time.Now().Unix()) + + // ── Run the agent (synchronous) ──────────────────────────────────── + // Use "openai" as the channel so routing/history work naturally. + response, err := al.ProcessDirectWithChannel(r.Context(), userMessage, sessionKey, "openai", "api") + + // ── Streaming response (SSE) ──────────────────────────────────────── + if req.Stream { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + if err != nil { + // Emit the error as content and finish cleanly. + writeOpenAISSE(w, buildChunk(runID, model, created, + map[string]any{"role": "assistant", "content": fmt.Sprintf("Error: %v", err)}, + ptrStr("stop"))) + writeOpenAIDone(w) + return + } + + // 1) role chunk + writeOpenAISSE(w, buildChunk(runID, model, created, + map[string]any{"role": "assistant"}, nil)) + + // 2) content chunk + writeOpenAISSE(w, buildChunk(runID, model, created, + map[string]any{"content": response}, nil)) + + // 3) finish chunk + writeOpenAISSE(w, buildChunk(runID, model, created, + map[string]any{}, ptrStr("stop"))) + + writeOpenAIDone(w) + return + } + + // ── Non-streaming JSON response ───────────────────────────────────── + w.Header().Set("Content-Type", "application/json") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": err.Error(), + "type": "api_error", + }, + }) + return + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]any{ + "id": runID, + "object": "chat.completion", + "created": created, + "model": model, + "choices": []any{ + map[string]any{ + "index": 0, + "message": map[string]any{ + "role": "assistant", + "content": response, + }, + "finish_reason": "stop", + }, + }, + "usage": map[string]any{ + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + }) + } +} + +// ── helpers ────────────────────────────────────────────────────────────────── + +func sendOpenAIError(w http.ResponseWriter, status int, errType, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]any{ + "error": map[string]any{ + "message": msg, + "type": errType, + }, + }) +} + +// buildChunk constructs an SSE chat.completion.chunk payload. +func buildChunk(id, model string, created int64, delta map[string]any, finishReason *string) map[string]any { + choice := map[string]any{ + "index": 0, + "delta": delta, + } + if finishReason != nil { + choice["finish_reason"] = *finishReason + } else { + choice["finish_reason"] = nil + } + return map[string]any{ + "id": id, + "object": "chat.completion.chunk", + "created": created, + "model": model, + "choices": []any{choice}, + } +} + +func ptrStr(s string) *string { return &s } + + diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 155e50b39..faa0a531c 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -312,12 +312,11 @@ func (m *Manager) StartAll(ctx context.Context) error { defer m.mu.Unlock() if len(m.channels) == 0 { - logger.WarnC("channels", "No channels enabled") - return errors.New("no channels enabled") + logger.InfoC("channels", "No chat channels enabled; gateway will serve HTTP API only") + } else { + logger.InfoC("channels", "Starting all channels") } - logger.InfoC("channels", "Starting all channels") - dispatchCtx, cancel := context.WithCancel(ctx) m.dispatchTask = &asyncTask{cancel: cancel} diff --git a/pkg/config/config.go b/pkg/config/config.go index 55d0cfb2c..e25ee5c86 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -55,6 +55,7 @@ type Config struct { Providers ProvidersConfig `json:"providers,omitempty"` ModelList []ModelConfig `json:"model_list"` // New model-centric provider configuration Gateway GatewayConfig `json:"gateway"` + Nodes NodesConfig `json:"nodes"` Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` Devices DevicesConfig `json:"devices"` @@ -488,8 +489,34 @@ func (c *ModelConfig) Validate() error { } type GatewayConfig struct { - Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` - Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` + Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` + Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` + // Token is an optional shared secret for the HTTP API. + // If non-empty, all /v1/chat/completions requests must provide + // "Authorization: Bearer ". Leave empty to allow all requests. + Token string `json:"token,omitempty" env:"PICOCLAW_GATEWAY_TOKEN"` +} + +// NodesConfig configures the openclaw node WebSocket server. +// Nodes (headless Linux, iOS, Android, macOS) connect to picoclaw using +// the openclaw gateway protocol (version 3). picoclaw acts as the server; +// nodes are clients with role="node". +type NodesConfig struct { + // Enabled controls whether the node WebSocket server starts. + Enabled bool `json:"enabled" env:"PICOCLAW_NODES_ENABLED"` + + // Host is the bind address (default "0.0.0.0"). + Host string `json:"host" env:"PICOCLAW_NODES_HOST"` + + // Port is the TCP listen port (default 18790). + // Note: 18789 is the standard openclaw gateway port; 18790 avoids collisions + // when picoclaw runs alongside openclaw. + Port int `json:"port" env:"PICOCLAW_NODES_PORT"` + + // Token is an optional shared secret for authentication. + // If non-empty, connecting nodes must provide it as auth.token. + // Leave empty to allow all connections (suitable for trusted networks). + Token string `json:"token,omitempty" env:"PICOCLAW_NODES_TOKEN"` } type BraveConfig struct { diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 44f4de7e9..0ec602a05 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -308,6 +308,12 @@ func DefaultConfig() *Config { Host: "127.0.0.1", Port: 18790, }, + Nodes: NodesConfig{ + Enabled: false, + Host: "0.0.0.0", + Port: 18790, // same port as gateway; node WS is served on the shared HTTP server + Token: "", + }, Tools: ToolsConfig{ MediaCleanup: MediaCleanupConfig{ Enabled: true, diff --git a/pkg/health/server.go b/pkg/health/server.go index 5609ebdf6..29aa7162f 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -12,6 +12,7 @@ import ( type Server struct { server *http.Server + mux *http.ServeMux mu sync.RWMutex ready bool checks map[string]Check @@ -34,6 +35,7 @@ type StatusResponse struct { func NewServer(host string, port int) *Server { mux := http.NewServeMux() s := &Server{ + mux: mux, ready: false, checks: make(map[string]Check), startTime: time.Now(), @@ -47,12 +49,18 @@ func NewServer(host string, port int) *Server { Addr: addr, Handler: mux, ReadTimeout: 5 * time.Second, - WriteTimeout: 5 * time.Second, + WriteTimeout: 30 * time.Second, // allow longer writes for chat responses } return s } +// RegisterHandler registers an additional HTTP handler on the server's mux. +// Must be called before Start/StartContext. +func (s *Server) RegisterHandler(pattern string, handler http.HandlerFunc) { + s.mux.HandleFunc(pattern, handler) +} + func (s *Server) Start() error { s.mu.Lock() s.ready = true @@ -155,11 +163,14 @@ func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) { }) } -// RegisterOnMux registers /health and /ready handlers onto the given mux. -// This allows the health endpoints to be served by a shared HTTP server. +// RegisterOnMux registers health endpoints and any handlers added via RegisterHandler +// onto the given mux. This allows the health server to be served by a shared HTTP server. +// Handlers added via RegisterHandler (e.g. /chat, /v1/chat/completions) are served via +// the catch-all "/" which forwards to the health server's mux. func (s *Server) RegisterOnMux(mux *http.ServeMux) { mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/ready", s.readyHandler) + mux.Handle("/", s.mux) // catch-all for RegisterHandler routes (/chat, /v1/chat/completions, etc.) } func statusString(ok bool) string { diff --git a/pkg/nodes/protocol.go b/pkg/nodes/protocol.go new file mode 100644 index 000000000..887d16dbd --- /dev/null +++ b/pkg/nodes/protocol.go @@ -0,0 +1,264 @@ +// Package nodes implements the openclaw gateway WebSocket protocol server, +// allowing openclaw nodes (headless Linux, iOS, Android, macOS) to connect +// directly to picoclaw and expose their capabilities. +package nodes + +// ProtocolVersion is the openclaw gateway protocol version this server supports. +const ProtocolVersion = 3 + +// TickIntervalMs is how often (ms) the server sends a tick keepalive event. +// Must match openclaw's TICK_INTERVAL_MS = 30_000. +// The node client's watchdog closes the connection if no tick arrives within +// tickIntervalMs * 2 (i.e. 60 s), so we must send one every 30 s. +const TickIntervalMs = 30_000 + +// MaxPayloadBytes is the maximum allowed incoming frame size (512 KB). +const MaxPayloadBytes = 512 * 1024 + +// MaxBufferedBytes is the per-connection send buffer limit (1.5 MB). +const MaxBufferedBytes = 1536 * 1024 + +// Frame type constants matching the openclaw gateway protocol. +const ( + FrameTypeReq = "req" + FrameTypeRes = "res" + FrameTypeEvent = "event" +) + +// Method constants used in the protocol. +const ( + MethodConnect = "connect" + MethodNodeInvokeResult = "node.invoke.result" + MethodNodeEvent = "node.event" +) + +// Event constants sent from server to node. +const ( + EventConnectChallenge = "connect.challenge" + EventNodeInvokeRequest = "node.invoke.request" +) + +// Error code constants matching openclaw's ErrorCodes. +const ( + ErrCodeInvalidRequest = "INVALID_REQUEST" + ErrCodeNotPaired = "NOT_PAIRED" + ErrCodeUnavailable = "UNAVAILABLE" + ErrCodeTimeout = "TIMEOUT" +) + +// ReqFrame is a JSON-RPC style request frame sent by clients. +// Example: {"type":"req","id":"","method":"connect","params":{...}} +type ReqFrame struct { + Type string `json:"type"` + ID string `json:"id"` + Method string `json:"method"` + Params map[string]any `json:"params,omitempty"` +} + +// ResFrame is a response frame sent by the server. +// Example: {"type":"res","id":"","ok":true,"payload":{...}} +type ResFrame struct { + Type string `json:"type"` + ID string `json:"id"` + Ok bool `json:"ok"` + Payload any `json:"payload,omitempty"` + Error *ErrBody `json:"error,omitempty"` +} + +// EventFrame is an event frame sent by the server to nodes. +// Example: {"type":"event","event":"node.invoke.request","payload":{...}} +type EventFrame struct { + Type string `json:"type"` + Event string `json:"event"` + Payload any `json:"payload,omitempty"` +} + +// ErrBody is the error body in a response frame. +type ErrBody struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// ConnectChallengePayload is the payload of the connect.challenge event. +// The server sends this immediately upon connection. +type ConnectChallengePayload struct { + Nonce string `json:"nonce"` + Ts int64 `json:"ts"` +} + +// ConnectParams is the parameters of the connect request sent by nodes. +// Matches the openclaw gateway protocol ConnectParams structure. +type ConnectParams struct { + // Protocol version range supported by the client. + MinProtocol int `json:"minProtocol"` + MaxProtocol int `json:"maxProtocol"` + + // Role: "node" or "operator" + Role string `json:"role"` + + // Scopes requested by the client. + Scopes []string `json:"scopes,omitempty"` + + // Client identity information. + Client ConnectClientInfo `json:"client"` + + // Optional auth credentials. + Auth *ConnectAuth `json:"auth,omitempty"` + + // Device identity (for nodes with a persistent device ID / key pair). + Device *ConnectDevice `json:"device,omitempty"` + + // Commands this node can handle (only valid for role=node). + Commands []string `json:"commands,omitempty"` + + // Capabilities this node exposes (e.g. "system", "camera"). + Caps []string `json:"caps,omitempty"` + + // Path env from the node host (for headless Linux nodes). + PathEnv string `json:"pathEnv,omitempty"` +} + +// ConnectClientInfo holds identifying information about the connecting client. +type ConnectClientInfo struct { + // Unique stable client identifier (e.g. "openclaw-node-host"). + ID string `json:"id"` + + // Human-readable display name (e.g. "My Raspberry Pi"). + DisplayName string `json:"displayName,omitempty"` + + // Platform: "linux", "darwin", "win32", "ios", "android". + Platform string `json:"platform,omitempty"` + + // Device family: "phone", "tablet", "desktop", "server", etc. + DeviceFamily string `json:"deviceFamily,omitempty"` + + // Hardware model identifier (e.g. "iPhone16,2"). + ModelIdentifier string `json:"modelIdentifier,omitempty"` + + // Client mode: "node", "operator", etc. + Mode string `json:"mode,omitempty"` + + // Client version string. + Version string `json:"version,omitempty"` + + // Instance ID: stable identifier for this running instance. + InstanceID string `json:"instanceId,omitempty"` +} + +// ConnectAuth holds authentication credentials. +type ConnectAuth struct { + // Shared token (set in gateway / picoclaw config). + Token string `json:"token,omitempty"` + + // Password auth (alternative to token). + Password string `json:"password,omitempty"` +} + +// ConnectDevice holds the cryptographic device identity of the connecting node. +// picoclaw uses this for pairing tracking; signature verification is optional. +type ConnectDevice struct { + // Stable device ID derived from the public key. + ID string `json:"id"` + + // Base64url-encoded Ed25519 public key. + PublicKey string `json:"publicKey"` + + // Base64url-encoded signature over the auth payload. + Signature string `json:"signature,omitempty"` + + // Timestamp (ms since epoch) when the signature was created. + SignedAt int64 `json:"signedAt"` + + // Nonce from the connect.challenge event (for replay protection). + Nonce string `json:"nonce,omitempty"` +} + +// HelloOkPayload is the payload returned in the successful connect response. +type HelloOkPayload struct { + Type string `json:"type"` + Protocol int `json:"protocol"` + Server HelloOkServer `json:"server"` + Features HelloOkFeatures `json:"features"` + // Policy tells the client the tick interval so its watchdog is calibrated. + Policy HelloOkPolicy `json:"policy"` +} + +// HelloOkPolicy carries server-enforced connection policy sent in hello-ok. +type HelloOkPolicy struct { + MaxPayload int `json:"maxPayload"` + MaxBufferedBytes int `json:"maxBufferedBytes"` + // TickIntervalMs is how often the server will send tick events (ms). + // The node watchdog disconnects if no tick arrives within 2× this value. + TickIntervalMs int `json:"tickIntervalMs"` +} + +// HelloOkServer holds server identity info in the hello-ok response. +type HelloOkServer struct { + Version string `json:"version"` + Host string `json:"host"` + ConnID string `json:"connId"` +} + +// HelloOkFeatures lists supported methods and events in the hello-ok response. +type HelloOkFeatures struct { + Methods []string `json:"methods"` + Events []string `json:"events"` +} + +// NodeInvokeRequestPayload is the payload of the node.invoke.request event. +// Server sends this to the node to invoke a command. +type NodeInvokeRequestPayload struct { + // Unique request ID for correlating the response. + ID string `json:"id"` + + // The node ID the request is destined for. + NodeID string `json:"nodeId"` + + // The command to invoke on the node (e.g. "system.run"). + Command string `json:"command"` + + // JSON-encoded parameters for the command (may be null). + ParamsJSON *string `json:"paramsJSON,omitempty"` + + // Optional timeout for the command in milliseconds. + TimeoutMs *int `json:"timeoutMs,omitempty"` + + // Optional idempotency key for deduplication. + IdempotencyKey *string `json:"idempotencyKey,omitempty"` +} + +// NodeInvokeResultParams is the parameters of the node.invoke.result request. +// The node sends this back to the server after executing a command. +type NodeInvokeResultParams struct { + // Matches the ID from NodeInvokeRequestPayload. + ID string `json:"id"` + + // The node ID (must match the invoking node's ID). + NodeID string `json:"nodeId"` + + // Whether the invocation succeeded. + Ok bool `json:"ok"` + + // Structured result payload (alternative to PayloadJSON). + Payload any `json:"payload,omitempty"` + + // JSON-encoded result payload (preferred over Payload for large results). + PayloadJSON *string `json:"payloadJSON,omitempty"` + + // Error info if Ok is false. + Error *InvokeError `json:"error,omitempty"` +} + +// InvokeError represents an error from a node invoke. +type InvokeError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +// NodeEventParams is the parameters of the node.event request. +// Nodes send this to forward events to the server. +type NodeEventParams struct { + Event string `json:"event"` + PayloadJSON *string `json:"payloadJSON,omitempty"` + Payload any `json:"payload,omitempty"` +} diff --git a/pkg/nodes/registry.go b/pkg/nodes/registry.go new file mode 100644 index 000000000..18144f4d0 --- /dev/null +++ b/pkg/nodes/registry.go @@ -0,0 +1,450 @@ +package nodes + +import ( + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" +) + +// NodeSession holds the state of a connected node. +type NodeSession struct { + // NodeID is the stable identifier for this node (device.id or client.id). + NodeID string + + // ConnID is the unique connection identifier for this session. + ConnID string + + // DisplayName is the human-readable name of the node. + DisplayName string + + // Platform is the OS platform (linux, darwin, ios, android, etc.). + Platform string + + // DeviceFamily is the device category (phone, tablet, desktop, server, etc.). + DeviceFamily string + + // ModelIdentifier is the hardware model identifier. + ModelIdentifier string + + // Version is the client version string. + Version string + + // Commands lists the commands this node has declared it can handle. + Commands []string + + // Caps lists the capability groups this node exposes. + Caps []string + + // RemoteIP is the remote IP address of the connection (may be empty). + RemoteIP string + + // ConnectedAtMs is the unix millisecond timestamp when the node connected. + ConnectedAtMs int64 + + // conn is the underlying WebSocket connection. + conn *websocket.Conn + // mu protects conn sends. + mu sync.Mutex +} + +// SendEvent sends an event frame to this node. +// Returns an error if the send fails. +func (s *NodeSession) SendEvent(event string, payload any) error { + frame := EventFrame{ + Type: FrameTypeEvent, + Event: event, + Payload: payload, + } + data, err := json.Marshal(frame) + if err != nil { + return fmt.Errorf("marshal event: %w", err) + } + s.mu.Lock() + defer s.mu.Unlock() + return s.conn.WriteMessage(websocket.TextMessage, data) +} + +// HasCommand returns true if this node declared the given command. +func (s *NodeSession) HasCommand(cmd string) bool { + for _, c := range s.Commands { + if c == cmd { + return true + } + } + return false +} + +// pendingInvoke tracks an in-flight node invoke waiting for a result. +type pendingInvoke struct { + nodeID string + command string + resolve chan InvokeResult +} + +// InvokeResult holds the result of a node invoke operation. +type InvokeResult struct { + Ok bool + Payload any + PayloadJSON *string + Error *InvokeError +} + +// PairingRequest represents a node waiting for manual approval. +// When the server is configured with PairingMode="manual", new node connections +// are held here until an operator approves or rejects them. +type PairingRequest struct { + // RequestID is a unique ID for this pairing request. + RequestID string + + // NodeID is the connecting node's ID. + NodeID string + + // DisplayName is the connecting node's human-readable name. + DisplayName string + + // Platform is the OS of the connecting node. + Platform string + + // DeviceFamily is the device family of the connecting node. + DeviceFamily string + + // RemoteIP is the remote address of the connecting node. + RemoteIP string + + // RequestedAtMs is when the pairing request was created. + RequestedAtMs int64 + + // resolved is closed when the request is approved or rejected. + resolved chan bool +} + +// Registry tracks all connected nodes and manages in-flight invocations. +type Registry struct { + mu sync.RWMutex + nodesByID map[string]*NodeSession + nodesByConn map[string]string // connID -> nodeID + pendingByID map[string]*pendingInvoke + + pairingMu sync.RWMutex + pairingByID map[string]*PairingRequest // requestID -> PairingRequest + pairingByNodeID map[string]string // nodeID -> requestID +} + +// NewRegistry creates a new Registry. +func NewRegistry() *Registry { + return &Registry{ + nodesByID: make(map[string]*NodeSession), + nodesByConn: make(map[string]string), + pendingByID: make(map[string]*pendingInvoke), + pairingByID: make(map[string]*PairingRequest), + pairingByNodeID: make(map[string]string), + } +} + +// Register adds a new node session to the registry. +func (r *Registry) Register(connID string, conn *websocket.Conn, params *ConnectParams, remoteIP string) *NodeSession { + // Determine the stable node ID: prefer device.id, fall back to client.instanceId then client.id. + nodeID := "" + if params.Device != nil && params.Device.ID != "" { + nodeID = params.Device.ID + } else if params.Client.InstanceID != "" { + nodeID = params.Client.InstanceID + } else { + nodeID = params.Client.ID + } + + session := &NodeSession{ + NodeID: nodeID, + ConnID: connID, + DisplayName: params.Client.DisplayName, + Platform: params.Client.Platform, + DeviceFamily: params.Client.DeviceFamily, + ModelIdentifier: params.Client.ModelIdentifier, + Version: params.Client.Version, + Commands: params.Commands, + Caps: params.Caps, + RemoteIP: remoteIP, + ConnectedAtMs: time.Now().UnixMilli(), + conn: conn, + } + + r.mu.Lock() + r.nodesByID[nodeID] = session + r.nodesByConn[connID] = nodeID + r.mu.Unlock() + + return session +} + +// Unregister removes a node by connection ID. Returns the nodeID if found. +func (r *Registry) Unregister(connID string) string { + r.mu.Lock() + defer r.mu.Unlock() + + nodeID, ok := r.nodesByConn[connID] + if !ok { + return "" + } + delete(r.nodesByConn, connID) + delete(r.nodesByID, nodeID) + + // Fail any pending invokes for this node. + for id, pending := range r.pendingByID { + if pending.nodeID != nodeID { + continue + } + pending.resolve <- InvokeResult{ + Ok: false, + Error: &InvokeError{Code: ErrCodeUnavailable, Message: "node disconnected"}, + } + delete(r.pendingByID, id) + } + + return nodeID +} + +// Get returns the NodeSession for the given nodeID, or nil if not found. +func (r *Registry) Get(nodeID string) *NodeSession { + r.mu.RLock() + defer r.mu.RUnlock() + return r.nodesByID[nodeID] +} + +// GetByConn returns the NodeSession for the given connection ID, or nil. +func (r *Registry) GetByConn(connID string) *NodeSession { + r.mu.RLock() + nodeID := r.nodesByConn[connID] + r.mu.RUnlock() + if nodeID == "" { + return nil + } + return r.Get(nodeID) +} + +// ListConnected returns a snapshot of all currently connected node sessions. +func (r *Registry) ListConnected() []*NodeSession { + r.mu.RLock() + defer r.mu.RUnlock() + result := make([]*NodeSession, 0, len(r.nodesByID)) + for _, s := range r.nodesByID { + result = append(result, s) + } + return result +} + +// Invoke sends a command to a node and waits for the result. +// The server sends a node.invoke.request event to the node; the node +// executes the command and replies with a node.invoke.result request. +func (r *Registry) Invoke(nodeID, command string, params any, timeoutMs int) InvokeResult { + node := r.Get(nodeID) + if node == nil { + return InvokeResult{ + Ok: false, + Error: &InvokeError{Code: ErrCodeUnavailable, Message: "node not connected"}, + } + } + + requestID := uuid.New().String() + ch := make(chan InvokeResult, 1) + + pending := &pendingInvoke{ + nodeID: nodeID, + command: command, + resolve: ch, + } + + r.mu.Lock() + r.pendingByID[requestID] = pending + r.mu.Unlock() + + // Build the invoke request payload. + var paramsJSONPtr *string + if params != nil { + data, err := json.Marshal(params) + if err == nil { + s := string(data) + paramsJSONPtr = &s + } + } + if timeoutMs <= 0 { + timeoutMs = 30_000 + } + payload := NodeInvokeRequestPayload{ + ID: requestID, + NodeID: nodeID, + Command: command, + ParamsJSON: paramsJSONPtr, + TimeoutMs: &timeoutMs, + } + + // Send the event to the node. + if err := node.SendEvent(EventNodeInvokeRequest, payload); err != nil { + r.mu.Lock() + delete(r.pendingByID, requestID) + r.mu.Unlock() + return InvokeResult{ + Ok: false, + Error: &InvokeError{Code: ErrCodeUnavailable, Message: "failed to send invoke to node"}, + } + } + + // Wait for the result with timeout. + timer := time.NewTimer(time.Duration(timeoutMs) * time.Millisecond) + defer timer.Stop() + + select { + case result := <-ch: + return result + case <-timer.C: + r.mu.Lock() + delete(r.pendingByID, requestID) + r.mu.Unlock() + return InvokeResult{ + Ok: false, + Error: &InvokeError{Code: ErrCodeTimeout, Message: "node invoke timed out"}, + } + } +} + +// HandleInvokeResult processes a node.invoke.result response from a node. +// Returns true if the result was matched to a pending invoke. +func (r *Registry) HandleInvokeResult(p *NodeInvokeResultParams) bool { + r.mu.Lock() + pending, ok := r.pendingByID[p.ID] + if !ok { + r.mu.Unlock() + return false + } + if pending.nodeID != p.NodeID { + r.mu.Unlock() + return false + } + delete(r.pendingByID, p.ID) + r.mu.Unlock() + + result := InvokeResult{ + Ok: p.Ok, + Payload: p.Payload, + PayloadJSON: p.PayloadJSON, + Error: p.Error, + } + pending.resolve <- result + return true +} + +// SendEvent sends an event to the node with the given nodeID. +func (r *Registry) SendEvent(nodeID, event string, payload any) bool { + node := r.Get(nodeID) + if node == nil { + return false + } + return node.SendEvent(event, payload) == nil +} + +// Disconnect forcibly closes the WebSocket connection for the given nodeID. +// This is used to reject a connected node. +func (r *Registry) Disconnect(nodeID string) bool { + node := r.Get(nodeID) + if node == nil { + return false + } + node.mu.Lock() + defer node.mu.Unlock() + _ = node.conn.Close() + return true +} + +// --- Pairing queue (for manual pairing mode) --- + +// AddPairingRequest adds a node to the pending pairing queue and blocks until +// approved or rejected (or the channel is closed). Returns true if approved. +func (r *Registry) AddPairingRequest(nodeID, displayName, platform, deviceFamily, remoteIP string) *PairingRequest { + req := &PairingRequest{ + RequestID: uuid.New().String(), + NodeID: nodeID, + DisplayName: displayName, + Platform: platform, + DeviceFamily: deviceFamily, + RemoteIP: remoteIP, + RequestedAtMs: time.Now().UnixMilli(), + resolved: make(chan bool, 1), + } + r.pairingMu.Lock() + r.pairingByID[req.RequestID] = req + r.pairingByNodeID[nodeID] = req.RequestID + r.pairingMu.Unlock() + return req +} + +// WaitPairingDecision blocks until the pairing request is resolved. +// Returns true if approved, false if rejected. +func (r *Registry) WaitPairingDecision(req *PairingRequest) bool { + return <-req.resolved +} + +// ListPairingRequests returns all pending pairing requests. +func (r *Registry) ListPairingRequests() []*PairingRequest { + r.pairingMu.RLock() + defer r.pairingMu.RUnlock() + result := make([]*PairingRequest, 0, len(r.pairingByID)) + for _, req := range r.pairingByID { + result = append(result, req) + } + return result +} + +// ApprovePairing approves the pairing request with the given requestID. +// Returns false if the request is not found. +func (r *Registry) ApprovePairing(requestID string) bool { + r.pairingMu.Lock() + req, ok := r.pairingByID[requestID] + if ok { + delete(r.pairingByID, requestID) + delete(r.pairingByNodeID, req.NodeID) + } + r.pairingMu.Unlock() + if !ok { + return false + } + req.resolved <- true + return true +} + +// RejectPairing rejects the pairing request with the given requestID. +// Returns false if the request is not found. +func (r *Registry) RejectPairing(requestID string) bool { + r.pairingMu.Lock() + req, ok := r.pairingByID[requestID] + if ok { + delete(r.pairingByID, requestID) + delete(r.pairingByNodeID, req.NodeID) + } + r.pairingMu.Unlock() + if !ok { + return false + } + req.resolved <- false + return true +} + +// RemovePairingRequest removes a pending request without notifying (used on disconnect). +func (r *Registry) RemovePairingRequest(nodeID string) { + r.pairingMu.Lock() + reqID, ok := r.pairingByNodeID[nodeID] + if ok { + delete(r.pairingByNodeID, nodeID) + if req, exists := r.pairingByID[reqID]; exists { + delete(r.pairingByID, reqID) + // Non-blocking: resolve as rejected so the goroutine unblocks. + select { + case req.resolved <- false: + default: + } + } + } + r.pairingMu.Unlock() +} diff --git a/pkg/nodes/server.go b/pkg/nodes/server.go new file mode 100644 index 000000000..38c8239eb --- /dev/null +++ b/pkg/nodes/server.go @@ -0,0 +1,414 @@ +package nodes + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/gorilla/websocket" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// ServerConfig configures the nodes WebSocket server. +type ServerConfig struct { + // Enabled controls whether the node server starts at all. + Enabled bool `json:"enabled"` + + // Host is the bind address (e.g. "0.0.0.0" or "127.0.0.1"). + Host string `json:"host"` + + // Port is the TCP port to listen on (default: 18789). + Port int `json:"port"` + + // Token is an optional shared secret. If non-empty, connecting nodes must + // provide it as auth.token. Leave empty to allow all connections. + Token string `json:"token"` +} + +// Server is a WebSocket server that accepts openclaw node connections. +// It speaks the openclaw gateway protocol (version 3), allowing headless +// Linux nodes, iOS, Android, and macOS apps to connect as "node" role clients. +type Server struct { + cfg ServerConfig + registry *Registry + upgrader websocket.Upgrader + httpSrv *http.Server +} + +// NewServer creates a new Server with the given configuration. +// The returned server shares the provided Registry with other components. +func NewServer(cfg ServerConfig, registry *Registry) *Server { + return &Server{ + cfg: cfg, + registry: registry, + upgrader: websocket.Upgrader{ + // Allow all origins since nodes may connect from any host. + CheckOrigin: func(r *http.Request) bool { return true }, + // Match the openclaw gateway max payload (10 MB). + ReadBufferSize: 1024 * 1024, + WriteBufferSize: 1024 * 1024, + }, + } +} + +// Start begins listening for WebSocket connections. +// It returns after the listener is bound, running the accept loop in background. +// Cancel ctx to gracefully shut down the server. +func (s *Server) Start(ctx context.Context) error { + addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port) + + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleHTTP) + + s.httpSrv = &http.Server{ + Addr: addr, + Handler: mux, + } + + ln, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("nodes server listen %s: %w", addr, err) + } + + logger.InfoCF("nodes", "Node server started", + map[string]any{"addr": addr, "token_required": s.cfg.Token != ""}) + + go func() { + if err := s.httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("nodes", "Node server error", map[string]any{"error": err.Error()}) + } + }() + + go func() { + <-ctx.Done() + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := s.httpSrv.Shutdown(shutCtx); err != nil { + logger.ErrorCF("nodes", "Node server shutdown error", map[string]any{"error": err.Error()}) + } + }() + + return nil +} + +// Handler returns the http.HandlerFunc that handles WebSocket node connections. +// Use this to mount the nodes endpoint onto an external HTTP server (e.g. the +// health/API server) instead of starting a dedicated listener with Start(). +// The caller is responsible for server lifecycle; this method is safe to call +// before or after Start(). +func (s *Server) Handler() http.HandlerFunc { + return s.handleHTTP +} + +// handleHTTP upgrades HTTP connections to WebSocket for node connections. +func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + // Upgrade errors are already written as HTTP responses. + return + } + + remoteIP := r.RemoteAddr + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + remoteIP = strings.SplitN(forwarded, ",", 2)[0] + } else if realIP := r.Header.Get("X-Real-IP"); realIP != "" { + remoteIP = realIP + } + // Strip port from remote addr. + if host, _, err := net.SplitHostPort(remoteIP); err == nil { + remoteIP = host + } + + go s.handleConnection(conn, remoteIP) +} + +// handleConnection runs the full lifecycle of a single node WebSocket connection. +func (s *Server) handleConnection(conn *websocket.Conn, remoteIP string) { + connID := uuid.New().String() + defer conn.Close() + + log := func(level, msg string, fields map[string]any) { + if fields == nil { + fields = map[string]any{} + } + fields["conn"] = connID + fields["remote"] = remoteIP + switch level { + case "info": + logger.InfoCF("nodes", msg, fields) + case "warn": + logger.WarnCF("nodes", msg, fields) + case "error": + logger.ErrorCF("nodes", msg, fields) + case "debug": + logger.DebugCF("nodes", msg, fields) + } + } + + send := func(v any) { + data, err := json.Marshal(v) + if err != nil { + return + } + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + log("warn", "send failed", map[string]any{"error": err.Error()}) + } + } + + sendRes := func(id string, ok bool, payload any, errBody *ErrBody) { + frame := ResFrame{ + Type: FrameTypeRes, + ID: id, + Ok: ok, + Payload: payload, + Error: errBody, + } + send(frame) + } + + // Step 1: Send connect.challenge immediately upon connection. + nonce := uuid.New().String() + send(EventFrame{ + Type: FrameTypeEvent, + Event: EventConnectChallenge, + Payload: ConnectChallengePayload{ + Nonce: nonce, + Ts: time.Now().UnixMilli(), + }, + }) + + log("debug", "sent connect.challenge", nil) + + // Step 2: Wait for the connect handshake request with a timeout. + handshakeDeadline := time.Now().Add(30 * time.Second) + if err := conn.SetReadDeadline(handshakeDeadline); err != nil { + log("warn", "set read deadline failed", map[string]any{"error": err.Error()}) + return + } + + _, rawMsg, err := conn.ReadMessage() + if err != nil { + log("debug", "read connect failed", map[string]any{"error": err.Error()}) + return + } + + // Remove deadline for subsequent messages. + if err := conn.SetReadDeadline(time.Time{}); err != nil { + log("warn", "clear read deadline failed", map[string]any{"error": err.Error()}) + } + + // Parse the frame. + var frame map[string]any + if err := json.Unmarshal(rawMsg, &frame); err != nil { + log("warn", "invalid JSON in connect frame", nil) + sendRes("", false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: "invalid JSON"}) + return + } + + frameType, _ := frame["type"].(string) + frameMethod, _ := frame["method"].(string) + frameID, _ := frame["id"].(string) + + if frameType != FrameTypeReq || frameMethod != MethodConnect { + msg := fmt.Sprintf("expected connect request, got type=%s method=%s", frameType, frameMethod) + log("warn", "invalid handshake", map[string]any{"msg": msg}) + sendRes(frameID, false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: msg}) + return + } + + // Parse ConnectParams from the "params" field. + paramsRaw, _ := frame["params"].(map[string]any) + paramsBytes, _ := json.Marshal(paramsRaw) + var params ConnectParams + if err := json.Unmarshal(paramsBytes, ¶ms); err != nil { + sendRes(frameID, false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: "invalid connect params"}) + return + } + + // Step 3: Validate protocol version. + if params.MaxProtocol < ProtocolVersion || params.MinProtocol > ProtocolVersion { + msg := fmt.Sprintf("protocol mismatch: client supports [%d..%d], server requires %d", + params.MinProtocol, params.MaxProtocol, ProtocolVersion) + log("warn", "protocol mismatch", map[string]any{ + "min": params.MinProtocol, + "max": params.MaxProtocol, + }) + sendRes(frameID, false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: msg}) + return + } + + // Step 4: Validate role (only "node" is accepted by this server). + if params.Role != "node" { + msg := fmt.Sprintf("unsupported role: %q (only 'node' is accepted)", params.Role) + log("warn", "invalid role", map[string]any{"role": params.Role}) + sendRes(frameID, false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: msg}) + return + } + + // Step 5: Authenticate if a token is configured. + if s.cfg.Token != "" { + token := "" + if params.Auth != nil { + token = params.Auth.Token + } + if token != s.cfg.Token { + log("warn", "unauthorized connection", nil) + sendRes(frameID, false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: "unauthorized"}) + return + } + } + + // Step 6: Validate nonce to prevent replay attacks (only for remote connections). + if params.Device != nil && params.Device.Nonce != "" { + if params.Device.Nonce != nonce { + log("warn", "nonce mismatch", map[string]any{ + "got": params.Device.Nonce, + "expected": nonce, + }) + sendRes(frameID, false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: "device nonce mismatch"}) + return + } + } + + // Step 7: Register the node. + session := s.registry.Register(connID, conn, ¶ms, remoteIP) + + // Build hello-ok response matching the openclaw gateway protocol. + // policy.tickIntervalMs is critical: the node client watchdog closes the + // connection if no tick event arrives within tickIntervalMs*2. We must + // broadcast a tick every TickIntervalMs milliseconds to keep nodes alive. + hostname, _ := os.Hostname() + helloOk := HelloOkPayload{ + Type: "hello-ok", + Protocol: ProtocolVersion, + Server: HelloOkServer{ + Version: "picoclaw", + Host: hostname, + ConnID: connID, + }, + Features: HelloOkFeatures{ + Methods: []string{MethodNodeInvokeResult, MethodNodeEvent}, + Events: []string{EventConnectChallenge, EventNodeInvokeRequest, "tick"}, + }, + Policy: HelloOkPolicy{ + MaxPayload: MaxPayloadBytes, + MaxBufferedBytes: MaxBufferedBytes, + TickIntervalMs: TickIntervalMs, + }, + } + + sendRes(frameID, true, helloOk, nil) + + log("info", "node connected", map[string]any{ + "node_id": session.NodeID, + "display_name": session.DisplayName, + "platform": session.Platform, + "commands": session.Commands, + }) + + defer func() { + nodeID := s.registry.Unregister(connID) + if nodeID != "" { + log("info", "node disconnected", map[string]any{"node_id": nodeID}) + } + }() + + // Step 8a: Start tick keepalive goroutine. + // The openclaw node client's watchdog disconnects if it doesn't receive a + // tick event within tickIntervalMs*2. We send one every TickIntervalMs. + tickDone := make(chan struct{}) + go func() { + ticker := time.NewTicker(TickIntervalMs * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-tickDone: + return + case t := <-ticker.C: + send(EventFrame{ + Type: FrameTypeEvent, + Event: "tick", + Payload: map[string]any{"ts": t.UnixMilli()}, + }) + } + } + }() + defer close(tickDone) + + // Step 8b: Handle subsequent requests from the node. + for { + _, rawMsg, err := conn.ReadMessage() + if err != nil { + if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) { + log("debug", "connection read error", map[string]any{"error": err.Error()}) + } + return + } + + var req map[string]any + if err := json.Unmarshal(rawMsg, &req); err != nil { + sendRes("", false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: "invalid JSON"}) + continue + } + + reqType, _ := req["type"].(string) + reqMethod, _ := req["method"].(string) + reqID, _ := req["id"].(string) + + if reqType != FrameTypeReq { + // Ignore non-request frames (events, etc.). + continue + } + + switch reqMethod { + case MethodNodeInvokeResult: + s.handleNodeInvokeResult(req, reqID, session, sendRes) + case MethodNodeEvent: + // Node is sending an event upward (e.g., voice, sensor data). + // For now, just acknowledge. Future: route to agent bus. + sendRes(reqID, true, map[string]any{"ok": true}, nil) + default: + log("warn", "unknown method", map[string]any{"method": reqMethod}) + sendRes(reqID, false, nil, &ErrBody{Code: ErrCodeInvalidRequest, + Message: fmt.Sprintf("unknown method: %s", reqMethod)}) + } + } +} + +// handleNodeInvokeResult processes a node.invoke.result request from a node. +func (s *Server) handleNodeInvokeResult( + req map[string]any, + reqID string, + session *NodeSession, + sendRes func(id string, ok bool, payload any, errBody *ErrBody), +) { + paramsRaw, _ := req["params"].(map[string]any) + paramsBytes, _ := json.Marshal(paramsRaw) + var p NodeInvokeResultParams + if err := json.Unmarshal(paramsBytes, &p); err != nil { + sendRes(reqID, false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: "invalid params"}) + return + } + + // Security: the nodeId in the result must match the caller's nodeId. + if p.NodeID != session.NodeID { + sendRes(reqID, false, nil, &ErrBody{Code: ErrCodeInvalidRequest, Message: "nodeId mismatch"}) + return + } + + // Deliver the result to any waiting Invoke() call. + matched := s.registry.HandleInvokeResult(&p) + if !matched { + // Late-arriving result (after timeout) - acknowledged but ignored. + logger.DebugCF("nodes", "late invoke result ignored", + map[string]any{"id": p.ID, "node_id": p.NodeID}) + } + sendRes(reqID, true, map[string]any{"ok": true}, nil) +} diff --git a/pkg/tools/nodes.go b/pkg/tools/nodes.go new file mode 100644 index 000000000..0e5158444 --- /dev/null +++ b/pkg/tools/nodes.go @@ -0,0 +1,1037 @@ +package tools + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/nodes" +) + +// NodesTool exposes all connected openclaw node capabilities to agents. +// +// Supported actions match the original openclaw nodes-tool.ts and canvas-tool.ts: +// +// status – list all connected nodes +// describe – describe a specific node +// pending – list nodes pending manual approval (pairing queue) +// approve – approve a pairing request by requestId +// reject – reject a pairing request by requestId +// notify – send a push notification to a node (system.notify) +// camera_snap – take a photo from a node camera (camera.snap) +// camera_list – list cameras on a node (camera.list) +// camera_clip – record a short video clip (camera.clip) +// screen_record – record the node screen (screen.record) +// location_get – get current GPS location from a node (location.get) +// run – execute a shell command on a node (system.run) +// device_torch – turn device flashlight on/off via on:bool (device.torch) +// canvas_present – show/open node canvas WebView (canvas.present) +// canvas_hide – hide the canvas WebView (canvas.hide) +// canvas_navigate – navigate canvas to a URL (canvas.navigate) +// canvas_eval – execute JavaScript in the canvas (canvas.eval) +// canvas_snapshot – capture a screenshot of the canvas (canvas.snapshot) +// canvas_a2ui_push – push A2UI JSONL messages to the canvas (canvas.a2ui.pushJSONL) +// canvas_a2ui_reset– reset the A2UI canvas state (canvas.a2ui.reset) +type NodesTool struct { + registry *nodes.Registry +} + +// NewNodesTool creates a NodesTool that reads from the given Registry. +func NewNodesTool(registry *nodes.Registry) *NodesTool { + return &NodesTool{registry: registry} +} + +func (t *NodesTool) Name() string { return "nodes" } + +func (t *NodesTool) Description() string { + return "Discover and control paired nodes (status/describe/pairing/notify/camera/screen/location/run/device/canvas)." +} + +func (t *NodesTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "description": "Action to perform. " + + "Node discovery: status, describe. " + + "Pairing: pending, approve, reject. " + + "Notification: notify. " + + "Camera: camera_snap (photo), camera_list, camera_clip (video). " + + "Screen: screen_record. " + + "Location: location_get. " + + "Shell (desktop/server nodes only, NOT mobile): run. " + + "Device: device_torch (control flashlight/torch — on:true to turn on, on:false to turn off). " + + "Canvas/WebView (mobile & desktop nodes): " + + "canvas_present (open WebView, optional url+placement), " + + "canvas_hide (hide WebView), " + + "canvas_navigate (navigate to URL — use this to open a URL in the node browser), " + + "canvas_eval (execute JavaScript and return result), " + + "canvas_snapshot (screenshot the WebView), " + + "canvas_a2ui_push (push A2UI JSONL UI messages), " + + "canvas_a2ui_reset (reset A2UI state). ", + "enum": []string{ + "status", "describe", + "pending", "approve", "reject", + "notify", + "camera_snap", "camera_list", "camera_clip", + "screen_record", + "location_get", + "run", + "device_torch", + "canvas_present", "canvas_hide", "canvas_navigate", + "canvas_eval", "canvas_snapshot", + "canvas_a2ui_push", "canvas_a2ui_reset", + }, + }, + "node": map[string]any{ + "type": "string", + "description": "Target node ID or display name (required for most actions except status/pending).", + }, + "requestId": map[string]any{ + "type": "string", + "description": "Pairing request ID for approve/reject actions.", + }, + "timeoutMs": map[string]any{ + "type": "number", + "description": "Overall invoke timeout in milliseconds (default: 30000).", + }, + // notify + "title": map[string]any{ + "type": "string", + "description": "Notification title (notify action).", + }, + "body": map[string]any{ + "type": "string", + "description": "Notification body text (notify action).", + }, + "sound": map[string]any{ + "type": "string", + "description": "Notification sound name (notify action).", + }, + "priority": map[string]any{ + "type": "string", + "description": "Notification priority: passive | active | timeSensitive (notify action).", + "enum": []string{"passive", "active", "timeSensitive"}, + }, + "delivery": map[string]any{ + "type": "string", + "description": "Notification delivery channel: system | overlay | auto (notify action).", + "enum": []string{"system", "overlay", "auto"}, + }, + // camera_snap / camera_clip + "facing": map[string]any{ + "type": "string", + "description": "Camera facing: front | back | both (camera_snap: all three; camera_clip: front|back only).", + "enum": []string{"front", "back", "both"}, + }, + "maxWidth": map[string]any{ + "type": "number", + "description": "Maximum image width in pixels (camera_snap).", + }, + "quality": map[string]any{ + "type": "number", + "description": "JPEG quality 0–100 (camera_snap).", + }, + "delayMs": map[string]any{ + "type": "number", + "description": "Capture delay in milliseconds (camera_snap).", + }, + "deviceId": map[string]any{ + "type": "string", + "description": "Specific camera device ID (camera_snap / camera_clip).", + }, + "durationMs": map[string]any{ + "type": "number", + "description": "Recording duration in milliseconds (camera_clip / screen_record; default 3000/10000).", + }, + "includeAudio": map[string]any{ + "type": "boolean", + "description": "Whether to include audio (camera_clip / screen_record; default true).", + }, + // screen_record + "fps": map[string]any{ + "type": "number", + "description": "Recording frame rate (screen_record; default 10).", + }, + "screenIndex": map[string]any{ + "type": "number", + "description": "Screen index to record (screen_record; default 0).", + }, + "outPath": map[string]any{ + "type": "string", + "description": "Optional output file path (screen_record; default: temp file).", + }, + // location_get + "maxAgeMs": map[string]any{ + "type": "number", + "description": "Accept a cached location up to this many ms old (location_get).", + }, + "locationTimeoutMs": map[string]any{ + "type": "number", + "description": "Max time to wait for a fresh GPS fix (location_get).", + }, + "desiredAccuracy": map[string]any{ + "type": "string", + "description": "GPS accuracy hint: coarse | balanced | precise (location_get).", + "enum": []string{"coarse", "balanced", "precise"}, + }, + // run + "command": map[string]any{ + "type": "array", + "description": "Argv array for the command to run, e.g. [\"echo\",\"hello\"] (run action).", + "items": map[string]any{"type": "string"}, + }, + "cwd": map[string]any{ + "type": "string", + "description": "Working directory for the command (run action).", + }, + "env": map[string]any{ + "type": "array", + "description": "Extra environment variables as KEY=VALUE strings (run action).", + "items": map[string]any{"type": "string"}, + }, + "commandTimeoutMs": map[string]any{ + "type": "number", + "description": "Timeout for the remote command itself (run action).", + }, + // canvas_present + "url": map[string]any{ + "type": "string", + "description": "URL to load in the canvas (canvas_present / canvas_navigate).", + }, + "x": map[string]any{ + "type": "number", + "description": "Canvas placement x offset in pixels (canvas_present).", + }, + "y": map[string]any{ + "type": "number", + "description": "Canvas placement y offset in pixels (canvas_present).", + }, + "width": map[string]any{ + "type": "number", + "description": "Canvas placement width in pixels (canvas_present).", + }, + "height": map[string]any{ + "type": "number", + "description": "Canvas placement height in pixels (canvas_present).", + }, + // canvas_eval + "javaScript": map[string]any{ + "type": "string", + "description": "JavaScript code to execute in the canvas (canvas_eval).", + }, + // canvas_snapshot + "outputFormat": map[string]any{ + "type": "string", + "description": "Snapshot image format: png | jpg | jpeg (canvas_snapshot; default png).", + "enum": []string{"png", "jpg", "jpeg"}, + }, + // device_torch + "on": map[string]any{ + "type": "boolean", + "description": "Turn the torch on (true) or off (false) (device_torch action).", + }, + // canvas_a2ui_push + "jsonl": map[string]any{ + "type": "string", + "description": "A2UI JSONL payload string, one JSON object per line (canvas_a2ui_push).", + }, + }, + "required": []string{"action"}, + } +} + +// Execute dispatches the requested action to the appropriate registry call. +func (t *NodesTool) Execute(_ context.Context, args map[string]any) *ToolResult { + action, _ := args["action"].(string) + if action == "" { + return ErrorResult("action is required") + } + + timeoutMs := intArg(args, "timeoutMs", 30_000) + + switch action { + + // ── status ────────────────────────────────────────────────────────────── + case "status": + sessions := t.registry.ListConnected() + if len(sessions) == 0 { + return SilentResult("[]") + } + rows := make([]map[string]any, 0, len(sessions)) + for _, s := range sessions { + rows = append(rows, map[string]any{ + "nodeId": s.NodeID, + "displayName": s.DisplayName, + "platform": s.Platform, + "deviceFamily": s.DeviceFamily, + "version": s.Version, + "remoteIp": s.RemoteIP, + "connectedAtMs": s.ConnectedAtMs, + "caps": s.Caps, + "commands": s.Commands, + }) + } + return jsonResult(rows) + + // ── describe ───────────────────────────────────────────────────────────── + case "describe": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + s := t.registry.Get(nodeID) + if s == nil { + return ErrorResult(fmt.Sprintf("node %q not connected", nodeID)) + } + return jsonResult(map[string]any{ + "nodeId": s.NodeID, + "displayName": s.DisplayName, + "platform": s.Platform, + "deviceFamily": s.DeviceFamily, + "modelIdentifier": s.ModelIdentifier, + "version": s.Version, + "remoteIp": s.RemoteIP, + "connectedAtMs": s.ConnectedAtMs, + "caps": s.Caps, + "commands": s.Commands, + }) + + // ── pending ─────────────────────────────────────────────────────────────── + case "pending": + reqs := t.registry.ListPairingRequests() + if len(reqs) == 0 { + return SilentResult("[]") + } + rows := make([]map[string]any, 0, len(reqs)) + for _, r := range reqs { + rows = append(rows, map[string]any{ + "requestId": r.RequestID, + "nodeId": r.NodeID, + "displayName": r.DisplayName, + "platform": r.Platform, + "deviceFamily": r.DeviceFamily, + "remoteIp": r.RemoteIP, + "requestedAtMs": r.RequestedAtMs, + }) + } + return jsonResult(rows) + + // ── approve ─────────────────────────────────────────────────────────────── + case "approve": + reqID, _ := args["requestId"].(string) + if reqID == "" { + return ErrorResult("requestId is required for approve action") + } + if !t.registry.ApprovePairing(reqID) { + return ErrorResult(fmt.Sprintf("pairing request %q not found", reqID)) + } + return jsonResult(map[string]any{"ok": true, "requestId": reqID}) + + // ── reject ──────────────────────────────────────────────────────────────── + case "reject": + reqID, _ := args["requestId"].(string) + if reqID == "" { + return ErrorResult("requestId is required for reject action") + } + if !t.registry.RejectPairing(reqID) { + return ErrorResult(fmt.Sprintf("pairing request %q not found", reqID)) + } + return jsonResult(map[string]any{"ok": true, "requestId": reqID}) + + // ── notify ──────────────────────────────────────────────────────────────── + case "notify": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + title, _ := args["title"].(string) + body, _ := args["body"].(string) + if title == "" && body == "" { + return ErrorResult("title or body is required for notify action") + } + p := map[string]any{} + if title != "" { + p["title"] = title + } + if body != "" { + p["body"] = body + } + if s, ok := args["sound"].(string); ok && s != "" { + p["sound"] = s + } + if s, ok := args["priority"].(string); ok && s != "" { + p["priority"] = s + } + if s, ok := args["delivery"].(string); ok && s != "" { + p["delivery"] = s + } + result := t.registry.Invoke(nodeID, "system.notify", p, timeoutMs) + if !result.Ok { + return ErrorResult(invokeErrMsg(result)) + } + return jsonResult(map[string]any{"ok": true}) + + // ── camera_snap ─────────────────────────────────────────────────────────── + case "camera_snap": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + facing, _ := args["facing"].(string) + if facing == "" { + facing = "both" + } + facings := []string{} + switch facing { + case "front", "back": + facings = []string{facing} + default: // "both" + facings = []string{"front", "back"} + } + + p := map[string]any{"format": "jpg"} + if v := floatArg(args, "maxWidth"); v > 0 { + p["maxWidth"] = int(v) + } + if v := floatArg(args, "quality"); v > 0 { + p["quality"] = int(v) + } + if v := floatArg(args, "delayMs"); v > 0 { + p["delayMs"] = int(v) + } + if s, ok := args["deviceId"].(string); ok && s != "" { + p["deviceId"] = s + } + + type snapResult struct { + facing string + path string + width any + height any + } + var results []snapResult + + for _, f := range facings { + p["facing"] = f + res := t.registry.Invoke(nodeID, "camera.snap", p, timeoutMs) + if !res.Ok { + return ErrorResult(fmt.Sprintf("camera.snap facing=%s: %s", f, invokeErrMsg(res))) + } + payload, err := decodePayload(res) + if err != nil { + return ErrorResult(fmt.Sprintf("camera.snap parse: %v", err)) + } + b64, _ := payload["base64"].(string) + if b64 == "" { + return ErrorResult("camera.snap: empty base64 in response") + } + imgBytes, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + imgBytes, err = base64.RawStdEncoding.DecodeString(b64) + if err != nil { + return ErrorResult("camera.snap: base64 decode failed") + } + } + path := tempMediaPath("snap", f, "jpg") + if err := os.WriteFile(path, imgBytes, 0o644); err != nil { + return ErrorResult(fmt.Sprintf("camera.snap: write file: %v", err)) + } + results = append(results, snapResult{ + facing: f, + path: path, + width: payload["width"], + height: payload["height"], + }) + } + + details := make([]map[string]any, 0, len(results)) + files := "" + for _, r := range results { + files += fmt.Sprintf("MEDIA:%s\n", r.path) + details = append(details, map[string]any{ + "facing": r.facing, + "path": r.path, + "width": r.width, + "height": r.height, + }) + } + detailJSON, _ := json.Marshal(details) + return SilentResult(files + string(detailJSON)) + + // ── camera_list ─────────────────────────────────────────────────────────── + case "camera_list": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + res := t.registry.Invoke(nodeID, "camera.list", map[string]any{}, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + payload, err := decodePayload(res) + if err != nil { + return jsonResult(map[string]any{}) + } + return jsonResult(payload) + + // ── camera_clip ─────────────────────────────────────────────────────────── + case "camera_clip": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + facing, _ := args["facing"].(string) + if facing == "" { + facing = "front" + } + if facing != "front" && facing != "back" { + return ErrorResult("camera_clip facing must be front or back") + } + durationMs := intArg(args, "durationMs", 3_000) + includeAudio := true + if v, ok := args["includeAudio"].(bool); ok { + includeAudio = v + } + p := map[string]any{ + "facing": facing, + "durationMs": durationMs, + "includeAudio": includeAudio, + "format": "mp4", + } + if s, ok := args["deviceId"].(string); ok && s != "" { + p["deviceId"] = s + } + res := t.registry.Invoke(nodeID, "camera.clip", p, timeoutMs+durationMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + payload, err := decodePayload(res) + if err != nil { + return ErrorResult(fmt.Sprintf("camera.clip parse: %v", err)) + } + b64, _ := payload["base64"].(string) + if b64 == "" { + return ErrorResult("camera.clip: empty base64 in response") + } + vidBytes, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + vidBytes, err = base64.RawStdEncoding.DecodeString(b64) + if err != nil { + return ErrorResult("camera.clip: base64 decode failed") + } + } + path := tempMediaPath("clip", facing, "mp4") + if err := os.WriteFile(path, vidBytes, 0o644); err != nil { + return ErrorResult(fmt.Sprintf("camera.clip: write file: %v", err)) + } + return SilentResult(fmt.Sprintf("FILE:%s\n%s", path, mustJSON(map[string]any{ + "facing": facing, + "path": path, + "durationMs": payload["durationMs"], + "hasAudio": payload["hasAudio"], + }))) + + // ── screen_record ───────────────────────────────────────────────────────── + case "screen_record": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + durationMs := intArg(args, "durationMs", 10_000) + fps := intArg(args, "fps", 10) + screenIndex := intArg(args, "screenIndex", 0) + includeAudio := true + if v, ok := args["includeAudio"].(bool); ok { + includeAudio = v + } + p := map[string]any{ + "durationMs": durationMs, + "fps": fps, + "screenIndex": screenIndex, + "includeAudio": includeAudio, + "format": "mp4", + } + res := t.registry.Invoke(nodeID, "screen.record", p, timeoutMs+durationMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + payload, err := decodePayload(res) + if err != nil { + return ErrorResult(fmt.Sprintf("screen.record parse: %v", err)) + } + b64, _ := payload["base64"].(string) + if b64 == "" { + return ErrorResult("screen.record: empty base64 in response") + } + vidBytes, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + vidBytes, err = base64.RawStdEncoding.DecodeString(b64) + if err != nil { + return ErrorResult("screen.record: base64 decode failed") + } + } + outPath, _ := args["outPath"].(string) + if outPath == "" { + outPath = tempMediaPath("screen", fmt.Sprintf("idx%d", screenIndex), "mp4") + } + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return ErrorResult(fmt.Sprintf("screen.record: mkdir: %v", err)) + } + if err := os.WriteFile(outPath, vidBytes, 0o644); err != nil { + return ErrorResult(fmt.Sprintf("screen.record: write file: %v", err)) + } + return SilentResult(fmt.Sprintf("FILE:%s\n%s", outPath, mustJSON(map[string]any{ + "path": outPath, + "durationMs": payload["durationMs"], + "fps": payload["fps"], + "screenIndex": payload["screenIndex"], + "hasAudio": payload["hasAudio"], + }))) + + // ── location_get ────────────────────────────────────────────────────────── + case "location_get": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + p := map[string]any{} + if v := floatArg(args, "maxAgeMs"); v > 0 { + p["maxAgeMs"] = int(v) + } + if v := floatArg(args, "locationTimeoutMs"); v > 0 { + p["timeoutMs"] = int(v) + } + if s, ok := args["desiredAccuracy"].(string); ok && s != "" { + p["desiredAccuracy"] = s + } + res := t.registry.Invoke(nodeID, "location.get", p, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + payload, _ := decodePayload(res) + return jsonResult(payload) + + // ── run ─────────────────────────────────────────────────────────────────── + case "run": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + node := t.registry.Get(nodeID) + if node == nil { + return ErrorResult(fmt.Sprintf("node %q not connected", nodeID)) + } + if !node.HasCommand("system.run") { + return ErrorResult(fmt.Sprintf( + "node %q does not support system.run (available commands: %v)", + nodeID, node.Commands)) + } + + rawCmd, ok := args["command"] + if !ok || rawCmd == nil { + return ErrorResult("command is required (argv array, e.g. [\"echo\",\"hello\"])") + } + cmdSlice, ok := rawCmd.([]any) + if !ok { + return ErrorResult("command must be an array of strings") + } + cmd := make([]string, 0, len(cmdSlice)) + for _, v := range cmdSlice { + cmd = append(cmd, fmt.Sprintf("%v", v)) + } + if len(cmd) == 0 { + return ErrorResult("command must not be empty") + } + + // approved=true tells the node-host that picoclaw has already + // authorized this invocation, bypassing its exec-approval prompt. + p := map[string]any{"command": cmd, "approved": true} + if v, ok := args["cwd"].(string); ok && v != "" { + p["cwd"] = v + } + if v, ok := args["env"].([]any); ok && len(v) > 0 { + envPairs := make([]string, 0, len(v)) + for _, e := range v { + envPairs = append(envPairs, fmt.Sprintf("%v", e)) + } + p["env"] = envPairs + } + if v := floatArg(args, "commandTimeoutMs"); v > 0 { + p["timeoutMs"] = int(v) + } + + invokeTimeout := timeoutMs + if v := floatArg(args, "invokeTimeoutMs"); v > 0 { + invokeTimeout = int(v) + } + + res := t.registry.Invoke(nodeID, "system.run", p, invokeTimeout) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + payload, _ := decodePayload(res) + return jsonResult(payload) + + // ── device_torch ───────────────────────────────────────────────────────── + case "device_torch": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + + onVal, hasOn := args["on"].(bool) + if !hasOn { + return ErrorResult("device_torch requires on (boolean): true to turn on, false to turn off") + } + + res := t.registry.Invoke(nodeID, "device.torch", map[string]any{"on": onVal}, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + return jsonResult(map[string]any{"ok": true}) + + // ── canvas_present ──────────────────────────────────────────────────────── + case "canvas_present": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + p := map[string]any{} + if u, ok := args["url"].(string); ok && strings.TrimSpace(u) != "" { + p["url"] = strings.TrimSpace(u) + } + // Optional placement object; only set if at least one coordinate is given. + placement := map[string]any{} + if v, ok := args["x"].(float64); ok { + placement["x"] = int(v) + } + if v, ok := args["y"].(float64); ok { + placement["y"] = int(v) + } + if v, ok := args["width"].(float64); ok { + placement["width"] = int(v) + } + if v, ok := args["height"].(float64); ok { + placement["height"] = int(v) + } + if len(placement) > 0 { + p["placement"] = placement + } + res := t.registry.Invoke(nodeID, "canvas.present", p, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + return jsonResult(map[string]any{"ok": true}) + + // ── canvas_hide ─────────────────────────────────────────────────────────── + case "canvas_hide": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + res := t.registry.Invoke(nodeID, "canvas.hide", map[string]any{}, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + return jsonResult(map[string]any{"ok": true}) + + // ── canvas_navigate ─────────────────────────────────────────────────────── + case "canvas_navigate": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + u, ok := args["url"].(string) + if !ok || strings.TrimSpace(u) == "" { + return ErrorResult("url is required for canvas_navigate") + } + res := t.registry.Invoke(nodeID, "canvas.navigate", map[string]any{"url": strings.TrimSpace(u)}, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + return jsonResult(map[string]any{"ok": true}) + + // ── canvas_eval ─────────────────────────────────────────────────────────── + case "canvas_eval": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + js, ok := args["javaScript"].(string) + if !ok || strings.TrimSpace(js) == "" { + return ErrorResult("javaScript is required for canvas_eval") + } + res := t.registry.Invoke(nodeID, "canvas.eval", map[string]any{"javaScript": js}, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + payload, _ := decodePayload(res) + // Return the result string if present, otherwise ok. + if result, ok := payload["result"].(string); ok && result != "" { + return SilentResult(result) + } + return jsonResult(map[string]any{"ok": true}) + + // ── canvas_snapshot ─────────────────────────────────────────────────────── + case "canvas_snapshot": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + // Normalise format: jpg/jpeg → jpeg; anything else → png. + formatRaw := "png" + if f, ok := args["outputFormat"].(string); ok { + formatRaw = strings.ToLower(strings.TrimSpace(f)) + } + format := "png" + if formatRaw == "jpg" || formatRaw == "jpeg" { + format = "jpeg" + } + p := map[string]any{"format": format} + if v := floatArg(args, "maxWidth"); v > 0 { + p["maxWidth"] = int(v) + } + if v := floatArg(args, "quality"); v > 0 { + p["quality"] = int(v) + } + res := t.registry.Invoke(nodeID, "canvas.snapshot", p, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + payload, err := decodePayload(res) + if err != nil { + return ErrorResult(fmt.Sprintf("canvas.snapshot parse: %v", err)) + } + b64, _ := payload["base64"].(string) + if b64 == "" { + return ErrorResult("canvas.snapshot: empty base64 in response") + } + imgBytes, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + imgBytes, err = base64.RawStdEncoding.DecodeString(b64) + if err != nil { + return ErrorResult("canvas.snapshot: base64 decode failed") + } + } + ext := format // "png" or "jpeg" + if ext == "jpeg" { + ext = "jpg" + } + path := tempMediaPath("canvas", "snapshot", ext) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return ErrorResult(fmt.Sprintf("canvas.snapshot: mkdir: %v", err)) + } + if err := os.WriteFile(path, imgBytes, 0o644); err != nil { + return ErrorResult(fmt.Sprintf("canvas.snapshot: write file: %v", err)) + } + return SilentResult(fmt.Sprintf("MEDIA:%s\n%s", path, mustJSON(map[string]any{ + "format": format, + "path": path, + }))) + + // ── canvas_a2ui_push ────────────────────────────────────────────────────── + case "canvas_a2ui_push": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + jsonl, ok := args["jsonl"].(string) + if !ok || strings.TrimSpace(jsonl) == "" { + return ErrorResult("jsonl is required for canvas_a2ui_push") + } + res := t.registry.Invoke(nodeID, "canvas.a2ui.pushJSONL", map[string]any{"jsonl": jsonl}, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + return jsonResult(map[string]any{"ok": true}) + + // ── canvas_a2ui_reset ───────────────────────────────────────────────────── + case "canvas_a2ui_reset": + nodeID, err := t.resolveNode(args) + if err != nil { + return ErrorResult(err.Error()) + } + res := t.registry.Invoke(nodeID, "canvas.a2ui.reset", map[string]any{}, timeoutMs) + if !res.Ok { + return ErrorResult(invokeErrMsg(res)) + } + return jsonResult(map[string]any{"ok": true}) + + default: + return ErrorResult(fmt.Sprintf("unknown action: %q", action)) + } +} + +// ── helpers ──────────────────────────────────────────────────────────────────── + +// resolveNode resolves "node" arg to a nodeID by matching connected sessions. +// resolveNode resolves the "node" argument to a nodeID. +// Mirrors openclaw's resolveNodeIdFromList logic: +// - "current" / "default" / empty → picks the only connected node, or the +// first one when multiple are connected (auto-default). +// - Exact nodeId match. +// - Exact remoteIp match. +// - Normalized display-name match (lower-case, non-alnum → "-"). +// - Partial nodeId prefix match (query length ≥ 6). +func (t *NodesTool) resolveNode(args map[string]any) (string, error) { + ref, _ := args["node"].(string) + sessions := t.registry.ListConnected() + + // Auto-default: "current", "default", or empty query → pick the single + // connected node, or the first one if there are multiple. + if ref == "" || ref == "current" || ref == "default" { + if len(sessions) == 0 { + return "", fmt.Errorf("no nodes are currently connected") + } + return sessions[0].NodeID, nil + } + + qNorm := normalizeNodeKey(ref) + + var matches []*nodes.NodeSession + for _, s := range sessions { + if s.NodeID == ref { + return s.NodeID, nil // exact ID → return immediately + } + if s.RemoteIP == ref { + matches = append(matches, s) + continue + } + if s.DisplayName != "" && normalizeNodeKey(s.DisplayName) == qNorm { + matches = append(matches, s) + continue + } + // Partial prefix match (at least 6 chars to avoid false positives). + if len(ref) >= 6 && strings.HasPrefix(s.NodeID, ref) { + matches = append(matches, s) + } + } + + if len(matches) == 1 { + return matches[0].NodeID, nil + } + if len(matches) > 1 { + names := make([]string, len(matches)) + for i, s := range matches { + if s.DisplayName != "" { + names[i] = s.DisplayName + } else { + names[i] = s.NodeID + } + } + return "", fmt.Errorf("ambiguous node %q (matches: %s)", ref, strings.Join(names, ", ")) + } + + // Build a known-nodes hint for the error message. + known := make([]string, 0, len(sessions)) + for _, s := range sessions { + if s.DisplayName != "" { + known = append(known, s.DisplayName) + } else { + known = append(known, s.NodeID) + } + } + hint := "" + if len(known) > 0 { + hint = fmt.Sprintf(" (connected: %s)", strings.Join(known, ", ")) + } + return "", fmt.Errorf("no connected node matches %q%s", ref, hint) +} + +// normalizeNodeKey converts a string to a lowercase slug (non-alnum → "-"), +// matching openclaw's normalizeNodeKey used for display-name comparison. +func normalizeNodeKey(s string) string { + var b strings.Builder + prevDash := true // suppress leading dashes + for _, r := range strings.ToLower(s) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevDash = false + } else if !prevDash { + b.WriteByte('-') + prevDash = true + } + } + // Trim trailing dash. + result := b.String() + return strings.TrimRight(result, "-") +} + +// jsonResult marshals v and wraps it in a SilentResult. +func jsonResult(v any) *ToolResult { + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + return ErrorResult(fmt.Sprintf("json marshal error: %v", err)) + } + return SilentResult(string(data)) +} + +// mustJSON marshals v, returning "{}" on error. +func mustJSON(v any) string { + data, _ := json.Marshal(v) + return string(data) +} + +// invokeErrMsg formats an error message from an InvokeResult. +func invokeErrMsg(res nodes.InvokeResult) string { + if res.Error != nil { + if res.Error.Message != "" { + return res.Error.Message + } + return res.Error.Code + } + return "unknown error" +} + +// decodePayload extracts the payload map from an InvokeResult. +// It tries PayloadJSON first, then Payload directly. +func decodePayload(res nodes.InvokeResult) (map[string]any, error) { + if res.PayloadJSON != nil && *res.PayloadJSON != "" { + var m map[string]any + if err := json.Unmarshal([]byte(*res.PayloadJSON), &m); err != nil { + return nil, err + } + return m, nil + } + if res.Payload != nil { + if m, ok := res.Payload.(map[string]any); ok { + return m, nil + } + // Re-marshal round-trip. + data, _ := json.Marshal(res.Payload) + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + return nil, err + } + return m, nil + } + return map[string]any{}, nil +} + +// tempMediaPath returns a temp file path for node media files. +func tempMediaPath(kind, label, ext string) string { + ts := time.Now().UnixMilli() + name := fmt.Sprintf("node_%s_%s_%d.%s", kind, label, ts, ext) + return filepath.Join(os.TempDir(), "picoclaw", name) +} + +// intArg reads an integer parameter with a fallback default. +func intArg(args map[string]any, key string, defaultVal int) int { + if v, ok := args[key].(float64); ok && v > 0 { + return int(v) + } + return defaultVal +} + +// floatArg reads a numeric parameter (JSON numbers arrive as float64). +func floatArg(args map[string]any, key string) float64 { + v, _ := args[key].(float64) + return v +}