diff --git a/.env.example b/.env.example
index c450b6e..66539b6 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,8 @@
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...
# DISCORD_BOT_TOKEN=xxx
+# LINE_CHANNEL_SECRET=xxx
+# LINE_CHANNEL_ACCESS_TOKEN=xxx
# ── Web Search (optional) ────────────────
# BRAVE_SEARCH_API_KEY=BSA...
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 465d1d6..0f075b0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -16,5 +16,10 @@ jobs:
with:
go-version-file: go.mod
+ - name: fmt
+ run: |
+ make fmt
+ git diff --exit-code || (echo "::error::Code is not formatted. Run 'make fmt' and commit the changes." && exit 1)
+
- name: Build
run: make build-all
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 35ad87a..fac7597 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -32,6 +32,9 @@ jobs:
with:
go-version-file: go.mod
+ - name: Run go generate
+ run: go generate ./...
+
- name: Run go vet
run: go vet ./...
@@ -47,6 +50,9 @@ jobs:
with:
go-version-file: go.mod
+ - name: Run go generate
+ run: go generate ./...
+
- name: Run go test
run: go test ./...
diff --git a/.gitignore b/.gitignore
index 6ba4117..3e5cee8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ build/
*.out
/picoclaw
/picoclaw-test
+cmd/picoclaw/workspace
# Picoclaw specific
@@ -35,4 +36,8 @@ coverage.html
# Ralph workspace
ralph/
.ralph/
-tasks/
\ No newline at end of file
+tasks/
+
+# Editors
+.vscode/
+.idea/
diff --git a/Dockerfile b/Dockerfile
index 8db9955..59c28a1 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,19 +18,15 @@ RUN make build
# ============================================================
# Stage 2: Minimal runtime image
# ============================================================
-FROM alpine:3.21
+FROM alpine:3.23
-RUN apk add --no-cache ca-certificates tzdata
+RUN apk add --no-cache ca-certificates tzdata curl
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
-# Copy builtin skills
-COPY --from=builder /src/skills /opt/picoclaw/skills
-
# Create picoclaw home directory
-RUN mkdir -p /root/.picoclaw/workspace/skills && \
- cp -r /opt/picoclaw/skills/* /root/.picoclaw/workspace/skills/ 2>/dev/null || true
+RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]
diff --git a/Makefile b/Makefile
index 2defcce..058fdb7 100644
--- a/Makefile
+++ b/Makefile
@@ -63,16 +63,23 @@ BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
# Default target
all: build
+## generate: Run generate
+generate:
+ @echo "Run generate..."
+ @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
+ @$(GO) generate ./...
+ @echo "Run generate complete"
+
## build: Build the picoclaw binary for current platform
-build:
+build: generate
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
- $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
+ @$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
## build-all: Build picoclaw for all platforms
-build-all:
+build-all: generate
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
@@ -89,35 +96,8 @@ install: build
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
- @echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..."
- @mkdir -p $(WORKSPACE_SKILLS_DIR)
- @for skill in $(BUILTIN_SKILLS_DIR)/*/; do \
- if [ -d "$$skill" ]; then \
- skill_name=$$(basename "$$skill"); \
- if [ -f "$$skill/SKILL.md" ]; then \
- cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \
- echo " ✓ Installed skill: $$skill_name"; \
- fi; \
- fi; \
- done
@echo "Installation complete!"
-## install-skills: Install builtin skills to workspace
-install-skills:
- @echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..."
- @mkdir -p $(WORKSPACE_SKILLS_DIR)
- @for skill in $(BUILTIN_SKILLS_DIR)/*/; do \
- if [ -d "$$skill" ]; then \
- skill_name=$$(basename "$$skill"); \
- if [ -f "$$skill/SKILL.md" ]; then \
- mkdir -p $(WORKSPACE_SKILLS_DIR)/$$skill_name; \
- cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \
- echo " ✓ Installed skill: $$skill_name"; \
- fi; \
- fi; \
- done
- @echo "Skills installation complete!"
-
## uninstall: Remove picoclaw from system
uninstall:
@echo "Uninstalling $(BINARY_NAME)..."
@@ -139,6 +119,14 @@ clean:
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
+## fmt: Format Go code
+vet:
+ @$(GO) vet ./...
+
+## fmt: Format Go code
+test:
+ @$(GO) test ./...
+
## fmt: Format Go code
fmt:
@$(GO) fmt ./...
diff --git a/README.ja.md b/README.ja.md
index 48105ce..e33b312 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -223,7 +223,7 @@ picoclaw agent -m "What is 2+2?"
## 💬 チャットアプリ
-Telegram、Discord、QQ、DingTalk で PicoClaw と会話できます
+Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
| チャネル | セットアップ |
|---------|------------|
@@ -231,6 +231,7 @@ Telegram、Discord、QQ、DingTalk で PicoClaw と会話できます
| **Discord** | 簡単(Bot トークン + Intents) |
| **QQ** | 簡単(AppID + AppSecret) |
| **DingTalk** | 普通(アプリ認証情報) |
+| **LINE** | 普通(認証情報 + Webhook URL) |
Telegram(推奨)
@@ -314,7 +315,7 @@ picoclaw gateway
**1. Bot を作成**
-- [QQ オープンプラットフォーム](https://connect.qq.com/) にアクセス
+- [QQ オープンプラットフォーム](https://q.qq.com/#) にアクセス
- アプリケーションを作成 → **AppID** と **AppSecret** を取得
**2. 設定**
@@ -376,6 +377,56 @@ picoclaw gateway
+
+LINE
+
+**1. LINE 公式アカウントを作成**
+
+- [LINE Developers Console](https://developers.line.biz/) にアクセス
+- プロバイダーを作成 → Messaging API チャネルを作成
+- **チャネルシークレット** と **チャネルアクセストークン** をコピー
+
+**2. 設定**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Webhook URL を設定**
+
+LINE の Webhook には HTTPS が必要です。リバースプロキシまたはトンネルを使用してください:
+
+```bash
+# ngrok の例
+ngrok http 18791
+```
+
+LINE Developers Console で Webhook URL を `https://あなたのドメイン/webhook/line` に設定し、**Webhook の利用** を有効にしてください。
+
+**4. 起動**
+
+```bash
+picoclaw gateway
+```
+
+> グループチャットでは @メンション時のみ応答します。返信は元メッセージを引用する形式です。
+
+> **Docker Compose**: `picoclaw-gateway` サービスに `ports: ["18791:18791"]` を追加して Webhook ポートを公開してください。
+
+
+
## ⚙️ 設定
設定ファイル: `~/.picoclaw/config.json`
diff --git a/README.md b/README.md
index b11409f..a61a1ab 100644
--- a/README.md
+++ b/README.md
@@ -239,14 +239,15 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your picoclaw through Telegram, Discord, or DingTalk
+Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
-| Channel | Setup |
-| ------------ | -------------------------- |
-| **Telegram** | Easy (just a token) |
-| **Discord** | Easy (bot token + intents) |
-| **QQ** | Easy (AppID + AppSecret) |
-| **DingTalk** | Medium (app credentials) |
+| Channel | Setup |
+| ------------ | ---------------------------------- |
+| **Telegram** | Easy (just a token) |
+| **Discord** | Easy (bot token + intents) |
+| **QQ** | Easy (AppID + AppSecret) |
+| **DingTalk** | Medium (app credentials) |
+| **LINE** | Medium (credentials + webhook URL) |
Telegram (Recommended)
@@ -334,8 +335,8 @@ picoclaw gateway
**1. Create a bot**
-* Go to [QQ Open Platform](https://connect.qq.com/)
-* Create an application → Get **AppID** and **AppSecret**
+- Go to [QQ Open Platform](https://q.qq.com/#)
+- Create an application → Get **AppID** and **AppSecret**
**2. Configure**
@@ -396,6 +397,56 @@ picoclaw gateway
+
+LINE
+
+**1. Create a LINE Official Account**
+
+- Go to [LINE Developers Console](https://developers.line.biz/)
+- Create a provider → Create a Messaging API channel
+- Copy **Channel Secret** and **Channel Access Token**
+
+**2. Configure**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Set up Webhook URL**
+
+LINE requires HTTPS for webhooks. Use a reverse proxy or tunnel:
+
+```bash
+# Example with ngrok
+ngrok http 18791
+```
+
+Then set the Webhook URL in LINE Developers Console to `https://your-domain/webhook/line` and enable **Use webhook**.
+
+**4. Run**
+
+```bash
+picoclaw gateway
+```
+
+> In group chats, the bot responds only when @mentioned. Replies quote the original message.
+
+> **Docker Compose**: Add `ports: ["18791:18791"]` to the `picoclaw-gateway` service to expose the webhook port.
+
+
+
##
Join the Agent Social Network
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
diff --git a/README.zh.md b/README.zh.md
index f2c9bf7..f94abce 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -342,7 +342,7 @@ picoclaw gateway
**1. 创建机器人**
-* 前往 [QQ 开放平台](https://connect.qq.com/)
+* 前往 [QQ 开放平台](https://q.qq.com/#)
* 创建应用 → 获取 **AppID** 和 **AppSecret**
**2. 配置**
diff --git a/assets/wechat.png b/assets/wechat.png
index 73b09da..0f97fa3 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 21246cf..2129662 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -9,8 +9,10 @@ package main
import (
"bufio"
"context"
+ "embed"
"fmt"
"io"
+ "io/fs"
"os"
"os/signal"
"path/filepath"
@@ -25,15 +27,21 @@ import (
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
+ "github.com/sipeed/picoclaw/pkg/devices"
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/migrate"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice"
)
+//go:generate cp -r ../../workspace .
+//go:embed workspace
+var embeddedFiles embed.FS
+
var (
version = "dev"
gitCommit string
@@ -227,10 +235,6 @@ func onboard() {
}
workspace := cfg.WorkspacePath()
- os.MkdirAll(workspace, 0755)
- os.MkdirAll(filepath.Join(workspace, "memory"), 0755)
- os.MkdirAll(filepath.Join(workspace, "skills"), 0755)
-
createWorkspaceTemplates(workspace)
fmt.Printf("%s picoclaw is ready!\n", logo)
@@ -240,170 +244,57 @@ func onboard() {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
+func copyEmbeddedToTarget(targetDir string) error {
+ // Ensure target directory exists
+ if err := os.MkdirAll(targetDir, 0755); err != nil {
+ return fmt.Errorf("Failed to create target directory: %w", err)
+ }
+
+ // Walk through all files in embed.FS
+ err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Skip directories
+ if d.IsDir() {
+ return nil
+ }
+
+ // Read embedded file
+ data, err := embeddedFiles.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
+ }
+
+ new_path, err := filepath.Rel("workspace", path)
+ if err != nil {
+ return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
+ }
+
+ // Build target file path
+ targetPath := filepath.Join(targetDir, new_path)
+
+ // Ensure target file's directory exists
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
+ }
+
+ // Write file
+ if err := os.WriteFile(targetPath, data, 0644); err != nil {
+ return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
+ }
+
+ return nil
+ })
+
+ return err
+}
+
func createWorkspaceTemplates(workspace string) {
- templates := map[string]string{
- "AGENTS.md": `# Agent Instructions
-
-You are a helpful AI assistant. Be concise, accurate, and friendly.
-
-## Guidelines
-
-- Always explain what you're doing before taking actions
-- Ask for clarification when request is ambiguous
-- Use tools to help accomplish tasks
-- Remember important information in your memory files
-- Be proactive and helpful
-- Learn from user feedback
-`,
- "SOUL.md": `# Soul
-
-I am picoclaw, a lightweight AI assistant powered by AI.
-
-## Personality
-
-- Helpful and friendly
-- Concise and to the point
-- Curious and eager to learn
-- Honest and transparent
-
-## Values
-
-- Accuracy over speed
-- User privacy and safety
-- Transparency in actions
-- Continuous improvement
-`,
- "USER.md": `# User
-
-Information about user goes here.
-
-## Preferences
-
-- Communication style: (casual/formal)
-- Timezone: (your timezone)
-- Language: (your preferred language)
-
-## Personal Information
-
-- Name: (optional)
-- Location: (optional)
-- Occupation: (optional)
-
-## Learning Goals
-
-- What the user wants to learn from AI
-- Preferred interaction style
-- Areas of interest
-`,
- "IDENTITY.md": `# Identity
-
-## Name
-PicoClaw 🦞
-
-## Description
-Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
-
-## Version
-0.1.0
-
-## Purpose
-- Provide intelligent AI assistance with minimal resource usage
-- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
-- Enable easy customization through skills system
-- Run on minimal hardware ($10 boards, <10MB RAM)
-
-## Capabilities
-
-- Web search and content fetching
-- File system operations (read, write, edit)
-- Shell command execution
-- Multi-channel messaging (Telegram, WhatsApp, Feishu)
-- Skill-based extensibility
-- Memory and context management
-
-## Philosophy
-
-- Simplicity over complexity
-- Performance over features
-- User control and privacy
-- Transparent operation
-- Community-driven development
-
-## Goals
-
-- Provide a fast, lightweight AI assistant
-- Support offline-first operation where possible
-- Enable easy customization and extension
-- Maintain high quality responses
-- Run efficiently on constrained hardware
-
-## License
-MIT License - Free and open source
-
-## Repository
-https://github.com/sipeed/picoclaw
-
-## Contact
-Issues: https://github.com/sipeed/picoclaw/issues
-Discussions: https://github.com/sipeed/picoclaw/discussions
-
----
-
-"Every bit helps, every bit matters."
-- Picoclaw
-`,
- }
-
- for filename, content := range templates {
- filePath := filepath.Join(workspace, filename)
- if _, err := os.Stat(filePath); os.IsNotExist(err) {
- os.WriteFile(filePath, []byte(content), 0644)
- fmt.Printf(" Created %s\n", filename)
- }
- }
-
- memoryDir := filepath.Join(workspace, "memory")
- os.MkdirAll(memoryDir, 0755)
- memoryFile := filepath.Join(memoryDir, "MEMORY.md")
- if _, err := os.Stat(memoryFile); os.IsNotExist(err) {
- memoryContent := `# Long-term Memory
-
-This file stores important information that should persist across sessions.
-
-## User Information
-
-(Important facts about user)
-
-## Preferences
-
-(User preferences learned over time)
-
-## Important Notes
-
-(Things to remember)
-
-## Configuration
-
-- Model preferences
-- Channel settings
-- Skills enabled
-`
- os.WriteFile(memoryFile, []byte(memoryContent), 0644)
- fmt.Println(" Created memory/MEMORY.md")
-
- skillsDir := filepath.Join(workspace, "skills")
- if _, err := os.Stat(skillsDir); os.IsNotExist(err) {
- os.MkdirAll(skillsDir, 0755)
- fmt.Println(" Created skills/")
- }
- }
-
- for filename, content := range templates {
- filePath := filepath.Join(workspace, filename)
- if _, err := os.Stat(filePath); os.IsNotExist(err) {
- os.WriteFile(filePath, []byte(content), 0644)
- fmt.Printf(" Created %s\n", filename)
- }
+ err := copyEmbeddedToTarget(workspace)
+ if err != nil {
+ fmt.Printf("Error copying workspace templates: %v\n", err)
}
}
@@ -751,6 +642,18 @@ func gatewayCmd() {
}
fmt.Println("✓ Heartbeat service started")
+ stateManager := state.NewManager(cfg.WorkspacePath())
+ deviceService := devices.NewService(devices.Config{
+ Enabled: cfg.Devices.Enabled,
+ MonitorUSB: cfg.Devices.MonitorUSB,
+ }, stateManager)
+ deviceService.SetBus(msgBus)
+ if err := deviceService.Start(ctx); err != nil {
+ fmt.Printf("Error starting device service: %v\n", err)
+ } else if cfg.Devices.Enabled {
+ fmt.Println("✓ Device event service started")
+ }
+
if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
}
@@ -763,6 +666,7 @@ func gatewayCmd() {
fmt.Println("\nShutting down...")
cancel()
+ deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
agentLoop.Stop()
diff --git a/config/config.example.json b/config/config.example.json
index c71587a..aa75c83 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -51,6 +51,23 @@
"bot_token": "xoxb-YOUR-BOT-TOKEN",
"app_token": "xapp-YOUR-APP-TOKEN",
"allow_from": []
+ },
+ "line": {
+ "enabled": false,
+ "channel_secret": "YOUR_LINE_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ },
+ "onebot": {
+ "enabled": false,
+ "ws_url": "ws://127.0.0.1:3001",
+ "access_token": "",
+ "reconnect_interval": 5,
+ "group_trigger_prefix": [],
+ "allow_from": []
}
},
"providers": {
@@ -104,6 +121,10 @@
"enabled": true,
"interval": 30
},
+ "devices": {
+ "enabled": false,
+ "monitor_usb": true
+ },
"gateway": {
"host": "0.0.0.0",
"port": 18790
diff --git a/go.mod b/go.mod
index f4c233e..98aecd6 100644
--- a/go.mod
+++ b/go.mod
@@ -13,18 +13,22 @@ require (
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mymmrac/telego v1.6.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
- github.com/openai/openai-go/v3 v3.21.0
+ github.com/openai/openai-go/v3 v3.22.0
github.com/slack-go/slack v0.17.3
github.com/tencent-connect/botgo v0.2.1
golang.org/x/oauth2 v0.35.0
)
+
+
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/github/copilot-sdk/go v0.1.23
+ github.com/google/jsonschema-go v0.4.2 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/grbit/go-json v0.11.0 // indirect
@@ -43,4 +47,5 @@ require (
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
+
)
diff --git a/go.sum b/go.sum
index 9174d28..6a565b9 100644
--- a/go.sum
+++ b/go.sum
@@ -32,6 +32,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
+github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
@@ -56,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
+github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -92,8 +96,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
-github.com/openai/openai-go/v3 v3.21.0 h1:3GpIR/W4q/v1uUOVuK3zYtQiF3DnRrZag/sxbtvEdtc=
-github.com/openai/openai-go/v3 v3.21.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
+github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
+github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index ac8da9f..73e8371 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -81,6 +81,10 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
}
registry.Register(tools.NewWebFetchTool(50000))
+ // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
+ registry.Register(tools.NewI2CTool())
+ registry.Register(tools.NewSPITool())
+
// Message tool - available to both agent and subagent
// Subagent uses it to communicate directly with user
messageTool := tools.NewMessageTool()
diff --git a/pkg/channels/base_test.go b/pkg/channels/base_test.go
index f82b04c..78c6d1d 100644
--- a/pkg/channels/base_test.go
+++ b/pkg/channels/base_test.go
@@ -50,4 +50,3 @@ func TestBaseChannelIsAllowed(t *testing.T) {
})
}
}
-
diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu_32.go
new file mode 100644
index 0000000..4e60fbc
--- /dev/null
+++ b/pkg/channels/feishu_32.go
@@ -0,0 +1,36 @@
+//go:build !amd64 && !arm64 && !riscv64 && !mips64 && !ppc64
+
+package channels
+
+import (
+ "context"
+ "errors"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+// FeishuChannel is a stub implementation for 32-bit architectures
+type FeishuChannel struct {
+ *BaseChannel
+}
+
+// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
+func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
+ return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config")
+}
+
+// Start is a stub method to satisfy the Channel interface
+func (c *FeishuChannel) Start(ctx context.Context) error {
+ return nil
+}
+
+// Stop is a stub method to satisfy the Channel interface
+func (c *FeishuChannel) Stop(ctx context.Context) error {
+ return nil
+}
+
+// Send is a stub method to satisfy the Channel interface
+func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ return errors.New("feishu channel is not supported on 32-bit architectures")
+}
diff --git a/pkg/channels/feishu.go b/pkg/channels/feishu_64.go
similarity index 98%
rename from pkg/channels/feishu.go
rename to pkg/channels/feishu_64.go
index 11dbd67..39dc40a 100644
--- a/pkg/channels/feishu.go
+++ b/pkg/channels/feishu_64.go
@@ -1,3 +1,5 @@
+//go:build amd64 || arm64 || riscv64 || mips64 || ppc64
+
package channels
import (
diff --git a/pkg/channels/line.go b/pkg/channels/line.go
new file mode 100644
index 0000000..ffb5533
--- /dev/null
+++ b/pkg/channels/line.go
@@ -0,0 +1,598 @@
+package channels
+
+import (
+ "bytes"
+ "context"
+ "crypto/hmac"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+const (
+ lineAPIBase = "https://api.line.me/v2/bot"
+ lineDataAPIBase = "https://api-data.line.me/v2/bot"
+ lineReplyEndpoint = lineAPIBase + "/message/reply"
+ linePushEndpoint = lineAPIBase + "/message/push"
+ lineContentEndpoint = lineDataAPIBase + "/message/%s/content"
+ lineBotInfoEndpoint = lineAPIBase + "/info"
+ lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
+ lineReplyTokenMaxAge = 25 * time.Second
+)
+
+type replyTokenEntry struct {
+ token string
+ timestamp time.Time
+}
+
+// LINEChannel implements the Channel interface for LINE Official Account
+// using the LINE Messaging API with HTTP webhook for receiving messages
+// and REST API for sending messages.
+type LINEChannel struct {
+ *BaseChannel
+ config config.LINEConfig
+ httpServer *http.Server
+ botUserID string // Bot's user ID
+ botBasicID string // Bot's basic ID (e.g. @216ru...)
+ botDisplayName string // Bot's display name for text-based mention detection
+ replyTokens sync.Map // chatID -> replyTokenEntry
+ quoteTokens sync.Map // chatID -> quoteToken (string)
+ ctx context.Context
+ cancel context.CancelFunc
+}
+
+// NewLINEChannel creates a new LINE channel instance.
+func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINEChannel, error) {
+ if cfg.ChannelSecret == "" || cfg.ChannelAccessToken == "" {
+ return nil, fmt.Errorf("line channel_secret and channel_access_token are required")
+ }
+
+ base := NewBaseChannel("line", cfg, messageBus, cfg.AllowFrom)
+
+ return &LINEChannel{
+ BaseChannel: base,
+ config: cfg,
+ }, nil
+}
+
+// Start launches the HTTP webhook server.
+func (c *LINEChannel) Start(ctx context.Context) error {
+ logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
+
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ // Fetch bot profile to get bot's userId for mention detection
+ if err := c.fetchBotInfo(); err != nil {
+ logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
+ "bot_user_id": c.botUserID,
+ "basic_id": c.botBasicID,
+ "display_name": c.botDisplayName,
+ })
+ }
+
+ mux := http.NewServeMux()
+ path := c.config.WebhookPath
+ if path == "" {
+ path = "/webhook/line"
+ }
+ mux.HandleFunc(path, c.webhookHandler)
+
+ addr := fmt.Sprintf("%s:%d", c.config.WebhookHost, c.config.WebhookPort)
+ c.httpServer = &http.Server{
+ Addr: addr,
+ Handler: mux,
+ }
+
+ go func() {
+ logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
+ "addr": addr,
+ "path": path,
+ })
+ if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
+ logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
+ "error": err.Error(),
+ })
+ }
+ }()
+
+ c.setRunning(true)
+ logger.InfoC("line", "LINE channel started (Webhook Mode)")
+ return nil
+}
+
+// fetchBotInfo retrieves the bot's userId, basicId, and displayName from the LINE API.
+func (c *LINEChannel) fetchBotInfo() error {
+ req, err := http.NewRequest(http.MethodGet, lineBotInfoEndpoint, nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("bot info API returned status %d", resp.StatusCode)
+ }
+
+ var info struct {
+ UserID string `json:"userId"`
+ BasicID string `json:"basicId"`
+ DisplayName string `json:"displayName"`
+ }
+ if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
+ return err
+ }
+
+ c.botUserID = info.UserID
+ c.botBasicID = info.BasicID
+ c.botDisplayName = info.DisplayName
+ return nil
+}
+
+// Stop gracefully shuts down the HTTP server.
+func (c *LINEChannel) Stop(ctx context.Context) error {
+ logger.InfoC("line", "Stopping LINE channel")
+
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ if c.httpServer != nil {
+ shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
+ logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{
+ "error": err.Error(),
+ })
+ }
+ }
+
+ c.setRunning(false)
+ logger.InfoC("line", "LINE channel stopped")
+ return nil
+}
+
+// webhookHandler handles incoming LINE webhook requests.
+func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ body, err := io.ReadAll(r.Body)
+ if err != nil {
+ logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
+ "error": err.Error(),
+ })
+ http.Error(w, "Bad request", http.StatusBadRequest)
+ return
+ }
+
+ signature := r.Header.Get("X-Line-Signature")
+ if !c.verifySignature(body, signature) {
+ logger.WarnC("line", "Invalid webhook signature")
+ http.Error(w, "Forbidden", http.StatusForbidden)
+ return
+ }
+
+ var payload struct {
+ Events []lineEvent `json:"events"`
+ }
+ if err := json.Unmarshal(body, &payload); err != nil {
+ logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
+ "error": err.Error(),
+ })
+ http.Error(w, "Bad request", http.StatusBadRequest)
+ return
+ }
+
+ // Return 200 immediately, process events asynchronously
+ w.WriteHeader(http.StatusOK)
+
+ for _, event := range payload.Events {
+ go c.processEvent(event)
+ }
+}
+
+// verifySignature validates the X-Line-Signature using HMAC-SHA256.
+func (c *LINEChannel) verifySignature(body []byte, signature string) bool {
+ if signature == "" {
+ return false
+ }
+
+ mac := hmac.New(sha256.New, []byte(c.config.ChannelSecret))
+ mac.Write(body)
+ expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
+
+ return hmac.Equal([]byte(expected), []byte(signature))
+}
+
+// LINE webhook event types
+type lineEvent struct {
+ Type string `json:"type"`
+ ReplyToken string `json:"replyToken"`
+ Source lineSource `json:"source"`
+ Message json.RawMessage `json:"message"`
+ Timestamp int64 `json:"timestamp"`
+}
+
+type lineSource struct {
+ Type string `json:"type"` // "user", "group", "room"
+ UserID string `json:"userId"`
+ GroupID string `json:"groupId"`
+ RoomID string `json:"roomId"`
+}
+
+type lineMessage struct {
+ ID string `json:"id"`
+ Type string `json:"type"` // "text", "image", "video", "audio", "file", "sticker"
+ Text string `json:"text"`
+ QuoteToken string `json:"quoteToken"`
+ Mention *struct {
+ Mentionees []lineMentionee `json:"mentionees"`
+ } `json:"mention"`
+ ContentProvider struct {
+ Type string `json:"type"`
+ } `json:"contentProvider"`
+}
+
+type lineMentionee struct {
+ Index int `json:"index"`
+ Length int `json:"length"`
+ Type string `json:"type"` // "user", "all"
+ UserID string `json:"userId"`
+}
+
+func (c *LINEChannel) processEvent(event lineEvent) {
+ if event.Type != "message" {
+ logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
+ "type": event.Type,
+ })
+ return
+ }
+
+ senderID := event.Source.UserID
+ chatID := c.resolveChatID(event.Source)
+ isGroup := event.Source.Type == "group" || event.Source.Type == "room"
+
+ var msg lineMessage
+ if err := json.Unmarshal(event.Message, &msg); err != nil {
+ logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
+ "error": err.Error(),
+ })
+ return
+ }
+
+ // In group chats, only respond when the bot is mentioned
+ if isGroup && !c.isBotMentioned(msg) {
+ logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{
+ "chat_id": chatID,
+ })
+ return
+ }
+
+ // Store reply token for later use
+ if event.ReplyToken != "" {
+ c.replyTokens.Store(chatID, replyTokenEntry{
+ token: event.ReplyToken,
+ timestamp: time.Now(),
+ })
+ }
+
+ // Store quote token for quoting the original message in reply
+ if msg.QuoteToken != "" {
+ c.quoteTokens.Store(chatID, msg.QuoteToken)
+ }
+
+ var content string
+ var mediaPaths []string
+ localFiles := []string{}
+
+ defer func() {
+ for _, file := range localFiles {
+ if err := os.Remove(file); err != nil {
+ logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
+ "file": file,
+ "error": err.Error(),
+ })
+ }
+ }
+ }()
+
+ switch msg.Type {
+ case "text":
+ content = msg.Text
+ // Strip bot mention from text in group chats
+ if isGroup {
+ content = c.stripBotMention(content, msg)
+ }
+ case "image":
+ localPath := c.downloadContent(msg.ID, "image.jpg")
+ if localPath != "" {
+ localFiles = append(localFiles, localPath)
+ mediaPaths = append(mediaPaths, localPath)
+ content = "[image]"
+ }
+ case "audio":
+ localPath := c.downloadContent(msg.ID, "audio.m4a")
+ if localPath != "" {
+ localFiles = append(localFiles, localPath)
+ mediaPaths = append(mediaPaths, localPath)
+ content = "[audio]"
+ }
+ case "video":
+ localPath := c.downloadContent(msg.ID, "video.mp4")
+ if localPath != "" {
+ localFiles = append(localFiles, localPath)
+ mediaPaths = append(mediaPaths, localPath)
+ content = "[video]"
+ }
+ case "file":
+ content = "[file]"
+ case "sticker":
+ content = "[sticker]"
+ default:
+ content = fmt.Sprintf("[%s]", msg.Type)
+ }
+
+ if strings.TrimSpace(content) == "" {
+ return
+ }
+
+ metadata := map[string]string{
+ "platform": "line",
+ "source_type": event.Source.Type,
+ "message_id": msg.ID,
+ }
+
+ logger.DebugCF("line", "Received message", map[string]interface{}{
+ "sender_id": senderID,
+ "chat_id": chatID,
+ "message_type": msg.Type,
+ "is_group": isGroup,
+ "preview": utils.Truncate(content, 50),
+ })
+
+ // Show typing/loading indicator (requires user ID, not group ID)
+ c.sendLoading(senderID)
+
+ c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
+}
+
+// isBotMentioned checks if the bot is mentioned in the message.
+// It first checks the mention metadata (userId match), then falls back
+// to text-based detection using the bot's display name, since LINE may
+// not include userId in mentionees for Official Accounts.
+func (c *LINEChannel) isBotMentioned(msg lineMessage) bool {
+ // Check mention metadata
+ if msg.Mention != nil {
+ for _, m := range msg.Mention.Mentionees {
+ if m.Type == "all" {
+ return true
+ }
+ if c.botUserID != "" && m.UserID == c.botUserID {
+ return true
+ }
+ }
+ // Mention metadata exists with mentionees but bot not matched by userId.
+ // The bot IS likely mentioned (LINE includes mention struct when bot is @-ed),
+ // so check if any mentionee overlaps with bot display name in text.
+ if c.botDisplayName != "" {
+ for _, m := range msg.Mention.Mentionees {
+ if m.Index >= 0 && m.Length > 0 {
+ runes := []rune(msg.Text)
+ end := m.Index + m.Length
+ if end <= len(runes) {
+ mentionText := string(runes[m.Index:end])
+ if strings.Contains(mentionText, c.botDisplayName) {
+ return true
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Fallback: text-based detection with display name
+ if c.botDisplayName != "" && strings.Contains(msg.Text, "@"+c.botDisplayName) {
+ return true
+ }
+
+ return false
+}
+
+// stripBotMention removes the @BotName mention text from the message.
+func (c *LINEChannel) stripBotMention(text string, msg lineMessage) string {
+ stripped := false
+
+ // Try to strip using mention metadata indices
+ if msg.Mention != nil {
+ runes := []rune(text)
+ for i := len(msg.Mention.Mentionees) - 1; i >= 0; i-- {
+ m := msg.Mention.Mentionees[i]
+ // Strip if userId matches OR if the mention text contains the bot display name
+ shouldStrip := false
+ if c.botUserID != "" && m.UserID == c.botUserID {
+ shouldStrip = true
+ } else if c.botDisplayName != "" && m.Index >= 0 && m.Length > 0 {
+ end := m.Index + m.Length
+ if end <= len(runes) {
+ mentionText := string(runes[m.Index:end])
+ if strings.Contains(mentionText, c.botDisplayName) {
+ shouldStrip = true
+ }
+ }
+ }
+ if shouldStrip {
+ start := m.Index
+ end := m.Index + m.Length
+ if start >= 0 && end <= len(runes) {
+ runes = append(runes[:start], runes[end:]...)
+ stripped = true
+ }
+ }
+ }
+ if stripped {
+ return strings.TrimSpace(string(runes))
+ }
+ }
+
+ // Fallback: strip @DisplayName from text
+ if c.botDisplayName != "" {
+ text = strings.ReplaceAll(text, "@"+c.botDisplayName, "")
+ }
+
+ return strings.TrimSpace(text)
+}
+
+// resolveChatID determines the chat ID from the event source.
+// For group/room messages, use the group/room ID; for 1:1, use the user ID.
+func (c *LINEChannel) resolveChatID(source lineSource) string {
+ switch source.Type {
+ case "group":
+ return source.GroupID
+ case "room":
+ return source.RoomID
+ default:
+ return source.UserID
+ }
+}
+
+// Send sends a message to LINE. It first tries the Reply API (free)
+// using a cached reply token, then falls back to the Push API.
+func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return fmt.Errorf("line channel not running")
+ }
+
+ // Load and consume quote token for this chat
+ var quoteToken string
+ if qt, ok := c.quoteTokens.LoadAndDelete(msg.ChatID); ok {
+ quoteToken = qt.(string)
+ }
+
+ // Try reply token first (free, valid for ~25 seconds)
+ if entry, ok := c.replyTokens.LoadAndDelete(msg.ChatID); ok {
+ tokenEntry := entry.(replyTokenEntry)
+ if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
+ if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
+ logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
+ "chat_id": msg.ChatID,
+ "quoted": quoteToken != "",
+ })
+ return nil
+ }
+ logger.DebugC("line", "Reply API failed, falling back to Push API")
+ }
+ }
+
+ // Fall back to Push API
+ return c.sendPush(ctx, msg.ChatID, msg.Content, quoteToken)
+}
+
+// buildTextMessage creates a text message object, optionally with quoteToken.
+func buildTextMessage(content, quoteToken string) map[string]string {
+ msg := map[string]string{
+ "type": "text",
+ "text": content,
+ }
+ if quoteToken != "" {
+ msg["quoteToken"] = quoteToken
+ }
+ return msg
+}
+
+// sendReply sends a message using the LINE Reply API.
+func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
+ payload := map[string]interface{}{
+ "replyToken": replyToken,
+ "messages": []map[string]string{buildTextMessage(content, quoteToken)},
+ }
+
+ return c.callAPI(ctx, lineReplyEndpoint, payload)
+}
+
+// sendPush sends a message using the LINE Push API.
+func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
+ payload := map[string]interface{}{
+ "to": to,
+ "messages": []map[string]string{buildTextMessage(content, quoteToken)},
+ }
+
+ return c.callAPI(ctx, linePushEndpoint, payload)
+}
+
+// sendLoading sends a loading animation indicator to the chat.
+func (c *LINEChannel) sendLoading(chatID string) {
+ payload := map[string]interface{}{
+ "chatId": chatID,
+ "loadingSeconds": 60,
+ }
+ if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
+ logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{
+ "error": err.Error(),
+ })
+ }
+}
+
+// callAPI makes an authenticated POST request to the LINE API.
+func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("failed to marshal payload: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.config.ChannelAccessToken)
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return fmt.Errorf("API request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ respBody, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("LINE API error (status %d): %s", resp.StatusCode, string(respBody))
+ }
+
+ return nil
+}
+
+// downloadContent downloads media content from the LINE API.
+func (c *LINEChannel) downloadContent(messageID, filename string) string {
+ url := fmt.Sprintf(lineContentEndpoint, messageID)
+ return utils.DownloadFile(url, filename, utils.DownloadOptions{
+ LoggerPrefix: "line",
+ ExtraHeaders: map[string]string{
+ "Authorization": "Bearer " + c.config.ChannelAccessToken,
+ },
+ })
+}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 772551a..15f8c60 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -150,6 +150,32 @@ func (m *Manager) initChannels() error {
}
}
+ if m.config.Channels.LINE.Enabled && m.config.Channels.LINE.ChannelAccessToken != "" {
+ logger.DebugC("channels", "Attempting to initialize LINE channel")
+ line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
+ if err != nil {
+ logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ m.channels["line"] = line
+ logger.InfoC("channels", "LINE channel enabled successfully")
+ }
+ }
+
+ if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
+ logger.DebugC("channels", "Attempting to initialize OneBot channel")
+ onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
+ if err != nil {
+ logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ m.channels["onebot"] = onebot
+ logger.InfoC("channels", "OneBot channel enabled successfully")
+ }
+ }
+
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
"enabled_channels": len(m.channels),
})
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
new file mode 100644
index 0000000..5d97fab
--- /dev/null
+++ b/pkg/channels/onebot.go
@@ -0,0 +1,686 @@
+package channels
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+type OneBotChannel struct {
+ *BaseChannel
+ config config.OneBotConfig
+ conn *websocket.Conn
+ ctx context.Context
+ cancel context.CancelFunc
+ dedup map[string]struct{}
+ dedupRing []string
+ dedupIdx int
+ mu sync.Mutex
+ writeMu sync.Mutex
+ echoCounter int64
+}
+
+type oneBotRawEvent struct {
+ PostType string `json:"post_type"`
+ MessageType string `json:"message_type"`
+ SubType string `json:"sub_type"`
+ MessageID json.RawMessage `json:"message_id"`
+ UserID json.RawMessage `json:"user_id"`
+ GroupID json.RawMessage `json:"group_id"`
+ RawMessage string `json:"raw_message"`
+ Message json.RawMessage `json:"message"`
+ Sender json.RawMessage `json:"sender"`
+ SelfID json.RawMessage `json:"self_id"`
+ Time json.RawMessage `json:"time"`
+ MetaEventType string `json:"meta_event_type"`
+ Echo string `json:"echo"`
+ RetCode json.RawMessage `json:"retcode"`
+ Status BotStatus `json:"status"`
+}
+
+type BotStatus struct {
+ Online bool `json:"online"`
+ Good bool `json:"good"`
+}
+
+type oneBotSender struct {
+ UserID json.RawMessage `json:"user_id"`
+ Nickname string `json:"nickname"`
+ Card string `json:"card"`
+}
+
+type oneBotEvent struct {
+ PostType string
+ MessageType string
+ SubType string
+ MessageID string
+ UserID int64
+ GroupID int64
+ Content string
+ RawContent string
+ IsBotMentioned bool
+ Sender oneBotSender
+ SelfID int64
+ Time int64
+ MetaEventType string
+}
+
+type oneBotAPIRequest struct {
+ Action string `json:"action"`
+ Params interface{} `json:"params"`
+ Echo string `json:"echo,omitempty"`
+}
+
+type oneBotSendPrivateMsgParams struct {
+ UserID int64 `json:"user_id"`
+ Message string `json:"message"`
+}
+
+type oneBotSendGroupMsgParams struct {
+ GroupID int64 `json:"group_id"`
+ Message string `json:"message"`
+}
+
+func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
+ base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
+
+ const dedupSize = 1024
+ return &OneBotChannel{
+ BaseChannel: base,
+ config: cfg,
+ dedup: make(map[string]struct{}, dedupSize),
+ dedupRing: make([]string, dedupSize),
+ dedupIdx: 0,
+ }, nil
+}
+
+func (c *OneBotChannel) Start(ctx context.Context) error {
+ if c.config.WSUrl == "" {
+ return fmt.Errorf("OneBot ws_url not configured")
+ }
+
+ logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{
+ "ws_url": c.config.WSUrl,
+ })
+
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ if err := c.connect(); err != nil {
+ logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ go c.listen()
+ }
+
+ if c.config.ReconnectInterval > 0 {
+ go c.reconnectLoop()
+ } else {
+ // If reconnect is disabled but initial connection failed, we cannot recover
+ if c.conn == nil {
+ return fmt.Errorf("failed to connect to OneBot and reconnect is disabled")
+ }
+ }
+
+ c.setRunning(true)
+ logger.InfoC("onebot", "OneBot channel started successfully")
+
+ return nil
+}
+
+func (c *OneBotChannel) connect() error {
+ dialer := websocket.DefaultDialer
+ dialer.HandshakeTimeout = 10 * time.Second
+
+ header := make(map[string][]string)
+ if c.config.AccessToken != "" {
+ header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
+ }
+
+ conn, _, err := dialer.Dial(c.config.WSUrl, header)
+ if err != nil {
+ return err
+ }
+
+ c.mu.Lock()
+ c.conn = conn
+ c.mu.Unlock()
+
+ logger.InfoC("onebot", "WebSocket connected")
+ return nil
+}
+
+func (c *OneBotChannel) reconnectLoop() {
+ interval := time.Duration(c.config.ReconnectInterval) * time.Second
+ if interval < 5*time.Second {
+ interval = 5 * time.Second
+ }
+
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-time.After(interval):
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ logger.InfoC("onebot", "Attempting to reconnect...")
+ if err := c.connect(); err != nil {
+ logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ go c.listen()
+ }
+ }
+ }
+ }
+}
+
+func (c *OneBotChannel) Stop(ctx context.Context) error {
+ logger.InfoC("onebot", "Stopping OneBot channel")
+ c.setRunning(false)
+
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ c.mu.Lock()
+ if c.conn != nil {
+ c.conn.Close()
+ c.conn = nil
+ }
+ c.mu.Unlock()
+
+ return nil
+}
+
+func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return fmt.Errorf("OneBot channel not running")
+ }
+
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ return fmt.Errorf("OneBot WebSocket not connected")
+ }
+
+ action, params, err := c.buildSendRequest(msg)
+ if err != nil {
+ return err
+ }
+
+ c.writeMu.Lock()
+ c.echoCounter++
+ echo := fmt.Sprintf("send_%d", c.echoCounter)
+ c.writeMu.Unlock()
+
+ req := oneBotAPIRequest{
+ Action: action,
+ Params: params,
+ Echo: echo,
+ }
+
+ data, err := json.Marshal(req)
+ if err != nil {
+ return fmt.Errorf("failed to marshal OneBot request: %w", err)
+ }
+
+ c.writeMu.Lock()
+ err = conn.WriteMessage(websocket.TextMessage, data)
+ c.writeMu.Unlock()
+
+ if err != nil {
+ logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{
+ "error": err.Error(),
+ })
+ return err
+ }
+
+ return nil
+}
+
+func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
+ chatID := msg.ChatID
+
+ if len(chatID) > 6 && chatID[:6] == "group:" {
+ groupID, err := strconv.ParseInt(chatID[6:], 10, 64)
+ if err != nil {
+ return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID)
+ }
+ return "send_group_msg", oneBotSendGroupMsgParams{
+ GroupID: groupID,
+ Message: msg.Content,
+ }, nil
+ }
+
+ if len(chatID) > 8 && chatID[:8] == "private:" {
+ userID, err := strconv.ParseInt(chatID[8:], 10, 64)
+ if err != nil {
+ return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID)
+ }
+ return "send_private_msg", oneBotSendPrivateMsgParams{
+ UserID: userID,
+ Message: msg.Content,
+ }, nil
+ }
+
+ userID, err := strconv.ParseInt(chatID, 10, 64)
+ if err != nil {
+ return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID)
+ }
+
+ return "send_private_msg", oneBotSendPrivateMsgParams{
+ UserID: userID,
+ Message: msg.Content,
+ }, nil
+}
+
+func (c *OneBotChannel) listen() {
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ default:
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
+ return
+ }
+
+ _, message, err := conn.ReadMessage()
+ if err != nil {
+ logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
+ "error": err.Error(),
+ })
+ c.mu.Lock()
+ if c.conn != nil {
+ c.conn.Close()
+ c.conn = nil
+ }
+ c.mu.Unlock()
+ return
+ }
+
+ logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{
+ "length": len(message),
+ "payload": string(message),
+ })
+
+ var raw oneBotRawEvent
+ if err := json.Unmarshal(message, &raw); err != nil {
+ logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
+ "error": err.Error(),
+ "payload": string(message),
+ })
+ continue
+ }
+
+ if raw.Echo != "" || raw.Status.Online || raw.Status.Good {
+ logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{
+ "echo": raw.Echo,
+ "status": raw.Status,
+ })
+ continue
+ }
+
+ logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{
+ "post_type": raw.PostType,
+ "message_type": raw.MessageType,
+ "sub_type": raw.SubType,
+ "meta_event_type": raw.MetaEventType,
+ })
+
+ c.handleRawEvent(&raw)
+ }
+ }
+}
+
+func parseJSONInt64(raw json.RawMessage) (int64, error) {
+ if len(raw) == 0 {
+ return 0, nil
+ }
+
+ var n int64
+ if err := json.Unmarshal(raw, &n); err == nil {
+ return n, nil
+ }
+
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ return strconv.ParseInt(s, 10, 64)
+ }
+ return 0, fmt.Errorf("cannot parse as int64: %s", string(raw))
+}
+
+func parseJSONString(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ return s
+ }
+
+ return string(raw)
+}
+
+type parseMessageResult struct {
+ Text string
+ IsBotMentioned bool
+}
+
+func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult {
+ if len(raw) == 0 {
+ return parseMessageResult{}
+ }
+
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ mentioned := false
+ if selfID > 0 {
+ cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
+ if strings.Contains(s, cqAt) {
+ mentioned = true
+ s = strings.ReplaceAll(s, cqAt, "")
+ s = strings.TrimSpace(s)
+ }
+ }
+ return parseMessageResult{Text: s, IsBotMentioned: mentioned}
+ }
+
+ var segments []map[string]interface{}
+ if err := json.Unmarshal(raw, &segments); err == nil {
+ var text string
+ mentioned := false
+ selfIDStr := strconv.FormatInt(selfID, 10)
+ for _, seg := range segments {
+ segType, _ := seg["type"].(string)
+ data, _ := seg["data"].(map[string]interface{})
+ switch segType {
+ case "text":
+ if data != nil {
+ if t, ok := data["text"].(string); ok {
+ text += t
+ }
+ }
+ case "at":
+ if data != nil && selfID > 0 {
+ qqVal := fmt.Sprintf("%v", data["qq"])
+ if qqVal == selfIDStr || qqVal == "all" {
+ mentioned = true
+ }
+ }
+ }
+ }
+ return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned}
+ }
+ return parseMessageResult{}
+}
+
+func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
+ switch raw.PostType {
+ case "message":
+ evt, err := c.normalizeMessageEvent(raw)
+ if err != nil {
+ logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{
+ "error": err.Error(),
+ })
+ return
+ }
+ c.handleMessage(evt)
+ case "meta_event":
+ c.handleMetaEvent(raw)
+ case "notice":
+ logger.DebugCF("onebot", "Notice event received", map[string]interface{}{
+ "sub_type": raw.SubType,
+ })
+ case "request":
+ logger.DebugCF("onebot", "Request event received", map[string]interface{}{
+ "sub_type": raw.SubType,
+ })
+ case "":
+ logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
+ "echo": raw.Echo,
+ "status": raw.Status,
+ })
+ default:
+ logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
+ "post_type": raw.PostType,
+ })
+ }
+}
+
+func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) {
+ userID, err := parseJSONInt64(raw.UserID)
+ if err != nil {
+ return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID))
+ }
+
+ groupID, _ := parseJSONInt64(raw.GroupID)
+ selfID, _ := parseJSONInt64(raw.SelfID)
+ ts, _ := parseJSONInt64(raw.Time)
+ messageID := parseJSONString(raw.MessageID)
+
+ parsed := parseMessageContentEx(raw.Message, selfID)
+ isBotMentioned := parsed.IsBotMentioned
+
+ content := raw.RawMessage
+ if content == "" {
+ content = parsed.Text
+ } else if selfID > 0 {
+ cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
+ if strings.Contains(content, cqAt) {
+ isBotMentioned = true
+ content = strings.ReplaceAll(content, cqAt, "")
+ content = strings.TrimSpace(content)
+ }
+ }
+
+ var sender oneBotSender
+ if len(raw.Sender) > 0 {
+ if err := json.Unmarshal(raw.Sender, &sender); err != nil {
+ logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
+ "error": err.Error(),
+ "sender": string(raw.Sender),
+ })
+ }
+ }
+
+ logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{
+ "message_type": raw.MessageType,
+ "user_id": userID,
+ "group_id": groupID,
+ "message_id": messageID,
+ "content_len": len(content),
+ "nickname": sender.Nickname,
+ })
+
+ return &oneBotEvent{
+ PostType: raw.PostType,
+ MessageType: raw.MessageType,
+ SubType: raw.SubType,
+ MessageID: messageID,
+ UserID: userID,
+ GroupID: groupID,
+ Content: content,
+ RawContent: raw.RawMessage,
+ IsBotMentioned: isBotMentioned,
+ Sender: sender,
+ SelfID: selfID,
+ Time: ts,
+ MetaEventType: raw.MetaEventType,
+ }, nil
+}
+
+func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
+ switch raw.MetaEventType {
+ case "lifecycle":
+ logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{
+ "sub_type": raw.SubType,
+ })
+ case "heartbeat":
+ logger.DebugC("onebot", "Heartbeat received")
+ default:
+ logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{
+ "meta_event_type": raw.MetaEventType,
+ })
+ }
+}
+
+func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
+ if c.isDuplicate(evt.MessageID) {
+ logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
+ "message_id": evt.MessageID,
+ })
+ return
+ }
+
+ content := evt.Content
+ if content == "" {
+ logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
+ "message_id": evt.MessageID,
+ })
+ return
+ }
+
+ senderID := strconv.FormatInt(evt.UserID, 10)
+ var chatID string
+
+ metadata := map[string]string{
+ "message_id": evt.MessageID,
+ }
+
+ switch evt.MessageType {
+ case "private":
+ chatID = "private:" + senderID
+ logger.InfoCF("onebot", "Received private message", map[string]interface{}{
+ "sender": senderID,
+ "message_id": evt.MessageID,
+ "length": len(content),
+ "content": truncate(content, 100),
+ })
+
+ case "group":
+ groupIDStr := strconv.FormatInt(evt.GroupID, 10)
+ chatID = "group:" + groupIDStr
+ metadata["group_id"] = groupIDStr
+
+ senderUserID, _ := parseJSONInt64(evt.Sender.UserID)
+ if senderUserID > 0 {
+ metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10)
+ }
+
+ if evt.Sender.Card != "" {
+ metadata["sender_name"] = evt.Sender.Card
+ } else if evt.Sender.Nickname != "" {
+ metadata["sender_name"] = evt.Sender.Nickname
+ }
+
+ triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned)
+ if !triggered {
+ logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
+ "sender": senderID,
+ "group": groupIDStr,
+ "is_mentioned": evt.IsBotMentioned,
+ "content": truncate(content, 100),
+ })
+ return
+ }
+ content = strippedContent
+
+ logger.InfoCF("onebot", "Received group message", map[string]interface{}{
+ "sender": senderID,
+ "group": groupIDStr,
+ "message_id": evt.MessageID,
+ "is_mentioned": evt.IsBotMentioned,
+ "length": len(content),
+ "content": truncate(content, 100),
+ })
+
+ default:
+ logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
+ "type": evt.MessageType,
+ "message_id": evt.MessageID,
+ "user_id": evt.UserID,
+ })
+ return
+ }
+
+ if evt.Sender.Nickname != "" {
+ metadata["nickname"] = evt.Sender.Nickname
+ }
+
+ logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{
+ "sender_id": senderID,
+ "chat_id": chatID,
+ "content": truncate(content, 100),
+ })
+
+ c.HandleMessage(senderID, chatID, content, []string{}, metadata)
+}
+
+func (c *OneBotChannel) isDuplicate(messageID string) bool {
+ if messageID == "" || messageID == "0" {
+ return false
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if _, exists := c.dedup[messageID]; exists {
+ return true
+ }
+
+ if old := c.dedupRing[c.dedupIdx]; old != "" {
+ delete(c.dedup, old)
+ }
+ c.dedupRing[c.dedupIdx] = messageID
+ c.dedup[messageID] = struct{}{}
+ c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing)
+
+ return false
+}
+
+func truncate(s string, n int) string {
+ runes := []rune(s)
+ if len(runes) <= n {
+ return s
+ }
+ return string(runes[:n]) + "..."
+}
+
+func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) {
+ if isBotMentioned {
+ return true, strings.TrimSpace(content)
+ }
+
+ for _, prefix := range c.config.GroupTriggerPrefix {
+ if prefix == "" {
+ continue
+ }
+ if strings.HasPrefix(content, prefix) {
+ return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
+ }
+ }
+
+ return false, content
+}
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index 0934dbd..b14b163 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -320,37 +320,14 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
}
}
- // Create new context for thinking animation with timeout
- thinkCtx, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
+ // Create cancel function for thinking state
+ _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
if err == nil {
pID := pMsg.MessageID
c.placeholders.Store(chatIDStr, pID)
-
- go func(cid int64, mid int) {
- dots := []string{".", "..", "..."}
- emotes := []string{"💭", "🤔", "☁️"}
- i := 0
- ticker := time.NewTicker(2000 * time.Millisecond)
- defer ticker.Stop()
- for {
- select {
- case <-thinkCtx.Done():
- return
- case <-ticker.C:
- i++
- text := fmt.Sprintf("Thinking%s %s", dots[i%len(dots)], emotes[i%len(emotes)])
- _, editErr := c.bot.EditMessageText(thinkCtx, tu.EditMessageText(tu.ID(chatID), mid, text))
- if editErr != nil {
- logger.DebugCF("telegram", "Failed to edit thinking message", map[string]interface{}{
- "error": editErr.Error(),
- })
- }
- }
- }
- }(chatID, pID)
}
metadata := map[string]string{
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 374c6f8..112f916 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -50,6 +50,7 @@ type Config struct {
Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
+ Devices DevicesConfig `json:"devices"`
mu sync.RWMutex
}
@@ -76,6 +77,8 @@ type ChannelsConfig struct {
QQ QQConfig `json:"qq"`
DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"`
+ LINE LINEConfig `json:"line"`
+ OneBot OneBotConfig `json:"onebot"`
}
type WhatsAppConfig struct {
@@ -134,29 +137,56 @@ type SlackConfig struct {
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}
+type LINEConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_LINE_ENABLED"`
+ ChannelSecret string `json:"channel_secret" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_SECRET"`
+ ChannelAccessToken string `json:"channel_access_token" env:"PICOCLAW_CHANNELS_LINE_CHANNEL_ACCESS_TOKEN"`
+ WebhookHost string `json:"webhook_host" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_HOST"`
+ WebhookPort int `json:"webhook_port" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PORT"`
+ WebhookPath string `json:"webhook_path" env:"PICOCLAW_CHANNELS_LINE_WEBHOOK_PATH"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
+}
+
+type OneBotConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
+ GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+}
+
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
}
+type DevicesConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_DEVICES_ENABLED"`
+ MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
+}
+
type ProvidersConfig struct {
- Anthropic ProviderConfig `json:"anthropic"`
- OpenAI ProviderConfig `json:"openai"`
- OpenRouter ProviderConfig `json:"openrouter"`
- Groq ProviderConfig `json:"groq"`
- Zhipu ProviderConfig `json:"zhipu"`
- VLLM ProviderConfig `json:"vllm"`
- Gemini ProviderConfig `json:"gemini"`
- Nvidia ProviderConfig `json:"nvidia"`
- Moonshot ProviderConfig `json:"moonshot"`
- ShengSuanYun ProviderConfig `json:"shengsuanyun"`
+ Anthropic ProviderConfig `json:"anthropic"`
+ OpenAI ProviderConfig `json:"openai"`
+ OpenRouter ProviderConfig `json:"openrouter"`
+ Groq ProviderConfig `json:"groq"`
+ Zhipu ProviderConfig `json:"zhipu"`
+ VLLM ProviderConfig `json:"vllm"`
+ Gemini ProviderConfig `json:"gemini"`
+ Nvidia ProviderConfig `json:"nvidia"`
+ Moonshot ProviderConfig `json:"moonshot"`
+ ShengSuanYun ProviderConfig `json:"shengsuanyun"`
+ DeepSeek ProviderConfig `json:"deepseek"`
+ GitHubCopilot ProviderConfig `json:"github_copilot"`
}
type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
}
type GatewayConfig struct {
@@ -245,6 +275,23 @@ func DefaultConfig() *Config {
AppToken: "",
AllowFrom: []string{},
},
+ LINE: LINEConfig{
+ Enabled: false,
+ ChannelSecret: "",
+ ChannelAccessToken: "",
+ WebhookHost: "0.0.0.0",
+ WebhookPort: 18791,
+ WebhookPath: "/webhook/line",
+ AllowFrom: FlexibleStringSlice{},
+ },
+ OneBot: OneBotConfig{
+ Enabled: false,
+ WSUrl: "ws://127.0.0.1:3001",
+ AccessToken: "",
+ ReconnectInterval: 5,
+ GroupTriggerPrefix: []string{},
+ AllowFrom: FlexibleStringSlice{},
+ },
},
Providers: ProvidersConfig{
Anthropic: ProviderConfig{},
@@ -279,6 +326,10 @@ func DefaultConfig() *Config {
Enabled: true,
Interval: 30, // default 30 minutes
},
+ Devices: DevicesConfig{
+ Enabled: false,
+ MonitorUSB: true,
+ },
}
}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 0a5e7b5..14618b1 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -136,11 +136,14 @@ func TestDefaultConfig_WebTools(t *testing.T) {
cfg := DefaultConfig()
// Verify web tools defaults
- if cfg.Tools.Web.Search.MaxResults != 5 {
- t.Error("Expected MaxResults 5, got ", cfg.Tools.Web.Search.MaxResults)
+ if cfg.Tools.Web.Brave.MaxResults != 5 {
+ t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
}
- if cfg.Tools.Web.Search.APIKey != "" {
- t.Error("Search API key should be empty by default")
+ if cfg.Tools.Web.Brave.APIKey != "" {
+ t.Error("Brave API key should be empty by default")
+ }
+ if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
+ t.Error("Expected DuckDuckGo MaxResults 5, got ", cfg.Tools.Web.DuckDuckGo.MaxResults)
}
}
diff --git a/pkg/devices/events/events.go b/pkg/devices/events/events.go
new file mode 100644
index 0000000..0122617
--- /dev/null
+++ b/pkg/devices/events/events.go
@@ -0,0 +1,57 @@
+package events
+
+import "context"
+
+type EventSource interface {
+ Kind() Kind
+ Start(ctx context.Context) (<-chan *DeviceEvent, error)
+ Stop() error
+}
+
+type Action string
+
+const (
+ ActionAdd Action = "add"
+ ActionRemove Action = "remove"
+ ActionChange Action = "change"
+)
+
+type Kind string
+
+const (
+ KindUSB Kind = "usb"
+ KindBluetooth Kind = "bluetooth"
+ KindPCI Kind = "pci"
+ KindGeneric Kind = "generic"
+)
+
+type DeviceEvent struct {
+ Action Action
+ Kind Kind
+ DeviceID string // e.g. "1-2" for USB bus 1 dev 2
+ Vendor string // Vendor name or ID
+ Product string // Product name or ID
+ Serial string // Serial number if available
+ Capabilities string // Human-readable capability description
+ Raw map[string]string // Raw properties for extensibility
+}
+
+func (e *DeviceEvent) FormatMessage() string {
+ actionEmoji := "🔌"
+ actionText := "Connected"
+ if e.Action == ActionRemove {
+ actionEmoji = "🔌"
+ actionText = "Disconnected"
+ }
+
+ msg := actionEmoji + " Device " + actionText + "\n\n"
+ msg += "Type: " + string(e.Kind) + "\n"
+ msg += "Device: " + e.Vendor + " " + e.Product + "\n"
+ if e.Capabilities != "" {
+ msg += "Capabilities: " + e.Capabilities + "\n"
+ }
+ if e.Serial != "" {
+ msg += "Serial: " + e.Serial + "\n"
+ }
+ return msg
+}
diff --git a/pkg/devices/service.go b/pkg/devices/service.go
new file mode 100644
index 0000000..05a2547
--- /dev/null
+++ b/pkg/devices/service.go
@@ -0,0 +1,152 @@
+package devices
+
+import (
+ "context"
+ "strings"
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/constants"
+ "github.com/sipeed/picoclaw/pkg/devices/events"
+ "github.com/sipeed/picoclaw/pkg/devices/sources"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/state"
+)
+
+type Service struct {
+ bus *bus.MessageBus
+ state *state.Manager
+ sources []events.EventSource
+ enabled bool
+ ctx context.Context
+ cancel context.CancelFunc
+ mu sync.RWMutex
+}
+
+type Config struct {
+ Enabled bool
+ MonitorUSB bool // When true, monitor USB hotplug (Linux only)
+ // Future: MonitorBluetooth, MonitorPCI, etc.
+}
+
+func NewService(cfg Config, stateMgr *state.Manager) *Service {
+ s := &Service{
+ state: stateMgr,
+ enabled: cfg.Enabled,
+ sources: make([]EventSource, 0),
+ }
+
+ if cfg.Enabled && cfg.MonitorUSB {
+ s.sources = append(s.sources, sources.NewUSBMonitor())
+ }
+
+ return s
+}
+
+func (s *Service) SetBus(msgBus *bus.MessageBus) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.bus = msgBus
+}
+
+func (s *Service) Start(ctx context.Context) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if !s.enabled || len(s.sources) == 0 {
+ logger.InfoC("devices", "Device event service disabled or no sources")
+ return nil
+ }
+
+ s.ctx, s.cancel = context.WithCancel(ctx)
+
+ for _, src := range s.sources {
+ eventCh, err := src.Start(s.ctx)
+ if err != nil {
+ logger.ErrorCF("devices", "Failed to start source", map[string]interface{}{
+ "kind": src.Kind(),
+ "error": err.Error(),
+ })
+ continue
+ }
+ go s.handleEvents(src.Kind(), eventCh)
+ logger.InfoCF("devices", "Device source started", map[string]interface{}{
+ "kind": src.Kind(),
+ })
+ }
+
+ logger.InfoC("devices", "Device event service started")
+ return nil
+}
+
+func (s *Service) Stop() {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.cancel != nil {
+ s.cancel()
+ s.cancel = nil
+ }
+
+ for _, src := range s.sources {
+ src.Stop()
+ }
+
+ logger.InfoC("devices", "Device event service stopped")
+}
+
+func (s *Service) handleEvents(kind events.Kind, eventCh <-chan *events.DeviceEvent) {
+ for ev := range eventCh {
+ if ev == nil {
+ continue
+ }
+ s.sendNotification(ev)
+ }
+}
+
+func (s *Service) sendNotification(ev *events.DeviceEvent) {
+ s.mu.RLock()
+ msgBus := s.bus
+ s.mu.RUnlock()
+
+ if msgBus == nil {
+ return
+ }
+
+ lastChannel := s.state.GetLastChannel()
+ if lastChannel == "" {
+ logger.DebugCF("devices", "No last channel, skipping notification", map[string]interface{}{
+ "event": ev.FormatMessage(),
+ })
+ return
+ }
+
+ platform, userID := parseLastChannel(lastChannel)
+ if platform == "" || userID == "" || constants.IsInternalChannel(platform) {
+ return
+ }
+
+ msg := ev.FormatMessage()
+ msgBus.PublishOutbound(bus.OutboundMessage{
+ Channel: platform,
+ ChatID: userID,
+ Content: msg,
+ })
+
+ logger.InfoCF("devices", "Device notification sent", map[string]interface{}{
+ "kind": ev.Kind,
+ "action": ev.Action,
+ "to": platform,
+ })
+}
+
+func parseLastChannel(lastChannel string) (platform, userID string) {
+ if lastChannel == "" {
+ return "", ""
+ }
+ parts := strings.SplitN(lastChannel, ":", 2)
+ if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
+ return "", ""
+ }
+ return parts[0], parts[1]
+}
diff --git a/pkg/devices/source.go b/pkg/devices/source.go
new file mode 100644
index 0000000..cbf0a7d
--- /dev/null
+++ b/pkg/devices/source.go
@@ -0,0 +1,5 @@
+package devices
+
+import "github.com/sipeed/picoclaw/pkg/devices/events"
+
+type EventSource = events.EventSource
diff --git a/pkg/devices/sources/usb_linux.go b/pkg/devices/sources/usb_linux.go
new file mode 100644
index 0000000..1f6c068
--- /dev/null
+++ b/pkg/devices/sources/usb_linux.go
@@ -0,0 +1,198 @@
+//go:build linux
+
+package sources
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "os/exec"
+ "strings"
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/devices/events"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+var usbClassToCapability = map[string]string{
+ "00": "Interface Definition (by interface)",
+ "01": "Audio",
+ "02": "CDC Communication (Network Card/Modem)",
+ "03": "HID (Keyboard/Mouse/Gamepad)",
+ "05": "Physical Interface",
+ "06": "Image (Scanner/Camera)",
+ "07": "Printer",
+ "08": "Mass Storage (USB Flash Drive/Hard Disk)",
+ "09": "USB Hub",
+ "0a": "CDC Data",
+ "0b": "Smart Card",
+ "0e": "Video (Camera)",
+ "dc": "Diagnostic Device",
+ "e0": "Wireless Controller (Bluetooth)",
+ "ef": "Miscellaneous",
+ "fe": "Application Specific",
+ "ff": "Vendor Specific",
+}
+
+type USBMonitor struct {
+ cmd *exec.Cmd
+ cancel context.CancelFunc
+ mu sync.Mutex
+}
+
+func NewUSBMonitor() *USBMonitor {
+ return &USBMonitor{}
+}
+
+func (m *USBMonitor) Kind() events.Kind {
+ return events.KindUSB
+}
+
+func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ // udevadm monitor outputs: UDEV/KERNEL [timestamp] action devpath (subsystem)
+ // Followed by KEY=value lines, empty line separates events
+ // Use -s/--subsystem-match (eudev) or --udev-subsystem-match (systemd udev)
+ cmd := exec.CommandContext(ctx, "udevadm", "monitor", "--property", "--subsystem-match=usb")
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("udevadm stdout pipe: %w", err)
+ }
+
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("udevadm start: %w (is udevadm installed?)", err)
+ }
+
+ m.cmd = cmd
+ eventCh := make(chan *events.DeviceEvent, 16)
+
+ go func() {
+ defer close(eventCh)
+ scanner := bufio.NewScanner(stdout)
+ var props map[string]string
+ var action string
+ isUdev := false // Only UDEV events have complete info (ID_VENDOR, ID_MODEL); KERNEL events come first with less info
+
+ for scanner.Scan() {
+ line := scanner.Text()
+ if line == "" {
+ // End of event block - only process UDEV events (skip KERNEL to avoid duplicate/incomplete notifications)
+ if isUdev && props != nil && (action == "add" || action == "remove") {
+ if ev := parseUSBEvent(action, props); ev != nil {
+ select {
+ case eventCh <- ev:
+ case <-ctx.Done():
+ return
+ }
+ }
+ }
+ props = nil
+ action = ""
+ isUdev = false
+ continue
+ }
+
+ idx := strings.Index(line, "=")
+ // First line of block: "UDEV [ts] action devpath" or "KERNEL[ts] action devpath" - no KEY=value
+ if idx <= 0 {
+ isUdev = strings.HasPrefix(strings.TrimSpace(line), "UDEV")
+ continue
+ }
+
+ // Parse KEY=value
+ key := line[:idx]
+ val := line[idx+1:]
+ if props == nil {
+ props = make(map[string]string)
+ }
+ props[key] = val
+
+ if key == "ACTION" {
+ action = val
+ }
+ }
+
+ if err := scanner.Err(); err != nil {
+ logger.ErrorCF("devices", "udevadm scan error", map[string]interface{}{"error": err.Error()})
+ }
+ cmd.Wait()
+ }()
+
+ return eventCh, nil
+}
+
+func (m *USBMonitor) Stop() error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ if m.cmd != nil && m.cmd.Process != nil {
+ m.cmd.Process.Kill()
+ m.cmd = nil
+ }
+ return nil
+}
+
+func parseUSBEvent(action string, props map[string]string) *events.DeviceEvent {
+ // Only care about add/remove for physical devices (not interfaces)
+ subsystem := props["SUBSYSTEM"]
+ if subsystem != "usb" {
+ return nil
+ }
+ // Skip interface events - we want device-level only to avoid duplicates
+ devType := props["DEVTYPE"]
+ if devType == "usb_interface" {
+ return nil
+ }
+ // Prefer usb_device, but accept if DEVTYPE not set (varies by udev version)
+ if devType != "" && devType != "usb_device" {
+ return nil
+ }
+
+ ev := &events.DeviceEvent{
+ Raw: props,
+ }
+ switch action {
+ case "add":
+ ev.Action = events.ActionAdd
+ case "remove":
+ ev.Action = events.ActionRemove
+ default:
+ return nil
+ }
+ ev.Kind = events.KindUSB
+
+ ev.Vendor = props["ID_VENDOR"]
+ if ev.Vendor == "" {
+ ev.Vendor = props["ID_VENDOR_ID"]
+ }
+ if ev.Vendor == "" {
+ ev.Vendor = "Unknown Vendor"
+ }
+
+ ev.Product = props["ID_MODEL"]
+ if ev.Product == "" {
+ ev.Product = props["ID_MODEL_ID"]
+ }
+ if ev.Product == "" {
+ ev.Product = "Unknown Device"
+ }
+
+ ev.Serial = props["ID_SERIAL_SHORT"]
+ ev.DeviceID = props["DEVPATH"]
+ if bus := props["BUSNUM"]; bus != "" {
+ if dev := props["DEVNUM"]; dev != "" {
+ ev.DeviceID = bus + ":" + dev
+ }
+ }
+
+ // Map USB class to capability
+ if class := props["ID_USB_CLASS"]; class != "" {
+ ev.Capabilities = usbClassToCapability[strings.ToLower(class)]
+ }
+ if ev.Capabilities == "" {
+ ev.Capabilities = "USB Device"
+ }
+
+ return ev
+}
diff --git a/pkg/devices/sources/usb_stub.go b/pkg/devices/sources/usb_stub.go
new file mode 100644
index 0000000..f08c2d4
--- /dev/null
+++ b/pkg/devices/sources/usb_stub.go
@@ -0,0 +1,29 @@
+//go:build !linux
+
+package sources
+
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/devices/events"
+)
+
+type USBMonitor struct{}
+
+func NewUSBMonitor() *USBMonitor {
+ return &USBMonitor{}
+}
+
+func (m *USBMonitor) Kind() events.Kind {
+ return events.KindUSB
+}
+
+func (m *USBMonitor) Start(ctx context.Context) (<-chan *events.DeviceEvent, error) {
+ ch := make(chan *events.DeviceEvent)
+ close(ch) // Immediately close, no events
+ return ch, nil
+}
+
+func (m *USBMonitor) Stop() error {
+ return nil
+}
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
new file mode 100644
index 0000000..5058819
--- /dev/null
+++ b/pkg/providers/github_copilot_provider.go
@@ -0,0 +1,82 @@
+package providers
+
+import (
+ "context"
+ "fmt"
+
+ json "encoding/json"
+
+ copilot "github.com/github/copilot-sdk/go"
+)
+
+type GitHubCopilotProvider struct {
+ uri string
+ connectMode string // `stdio` or `grpc``
+
+ session *copilot.Session
+}
+
+func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
+
+ var session *copilot.Session
+ if connectMode == "" {
+ connectMode = "grpc"
+ }
+ switch connectMode {
+
+ case "stdio":
+ //todo
+ case "grpc":
+ client := copilot.NewClient(&copilot.ClientOptions{
+ CLIUrl: uri,
+ })
+ if err := client.Start(context.Background()); err != nil {
+ return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
+ }
+ defer client.Stop()
+ session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
+ Model: model,
+ Hooks: &copilot.SessionHooks{},
+ })
+
+ }
+
+ return &GitHubCopilotProvider{
+ uri: uri,
+ connectMode: connectMode,
+ session: session,
+ }, nil
+}
+
+// Chat sends a chat request to GitHub Copilot
+func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+ type tempMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ }
+ out := make([]tempMessage, 0, len(messages))
+
+ for _, msg := range messages {
+ out = append(out, tempMessage{
+ Role: msg.Role,
+ Content: msg.Content,
+ })
+ }
+
+ fullcontent, _ := json.Marshal(out)
+
+ content, _ := p.session.Send(ctx, copilot.MessageOptions{
+ Prompt: string(fullcontent),
+ })
+
+ return &LLMResponse{
+ FinishReason: "stop",
+ Content: content,
+ }, nil
+
+}
+
+func (p *GitHubCopilotProvider) GetDefaultModel() string {
+
+ return "gpt-4.1"
+}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index fc78a18..0bea16d 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -303,7 +303,27 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
workspace = "."
}
return NewClaudeCliProvider(workspace), nil
+ case "deepseek":
+ if cfg.Providers.DeepSeek.APIKey != "" {
+ apiKey = cfg.Providers.DeepSeek.APIKey
+ apiBase = cfg.Providers.DeepSeek.APIBase
+ if apiBase == "" {
+ apiBase = "https://api.deepseek.com/v1"
+ }
+ if model != "deepseek-chat" && model != "deepseek-reasoner" {
+ model = "deepseek-chat"
+ }
+ }
+ case "github_copilot", "copilot":
+ if cfg.Providers.GitHubCopilot.APIBase != "" {
+ apiBase = cfg.Providers.GitHubCopilot.APIBase
+ } else {
+ apiBase = "localhost:4321"
+ }
+ return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
+
}
+
}
// Fallback: detect provider from model name
diff --git a/pkg/tools/i2c.go b/pkg/tools/i2c.go
new file mode 100644
index 0000000..abca5ec
--- /dev/null
+++ b/pkg/tools/i2c.go
@@ -0,0 +1,147 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "runtime"
+)
+
+// I2CTool provides I2C bus interaction for reading sensors and controlling peripherals.
+type I2CTool struct{}
+
+func NewI2CTool() *I2CTool {
+ return &I2CTool{}
+}
+
+func (t *I2CTool) Name() string {
+ return "i2c"
+}
+
+func (t *I2CTool) Description() string {
+ return "Interact with I2C bus devices for reading sensors and controlling peripherals. Actions: detect (list buses), scan (find devices on a bus), read (read bytes from device), write (send bytes to device). Linux only."
+}
+
+func (t *I2CTool) Parameters() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "action": map[string]interface{}{
+ "type": "string",
+ "enum": []string{"detect", "scan", "read", "write"},
+ "description": "Action to perform: detect (list available I2C buses), scan (find devices on a bus), read (read bytes from a device), write (send bytes to a device)",
+ },
+ "bus": map[string]interface{}{
+ "type": "string",
+ "description": "I2C bus number (e.g. \"1\" for /dev/i2c-1). Required for scan/read/write.",
+ },
+ "address": map[string]interface{}{
+ "type": "integer",
+ "description": "7-bit I2C device address (0x03-0x77). Required for read/write.",
+ },
+ "register": map[string]interface{}{
+ "type": "integer",
+ "description": "Register address to read from or write to. If set, sends register byte before read/write.",
+ },
+ "data": map[string]interface{}{
+ "type": "array",
+ "items": map[string]interface{}{"type": "integer"},
+ "description": "Bytes to write (0-255 each). Required for write action.",
+ },
+ "length": map[string]interface{}{
+ "type": "integer",
+ "description": "Number of bytes to read (1-256). Default: 1. Used with read action.",
+ },
+ "confirm": map[string]interface{}{
+ "type": "boolean",
+ "description": "Must be true for write operations. Safety guard to prevent accidental writes.",
+ },
+ },
+ "required": []string{"action"},
+ }
+}
+
+func (t *I2CTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+ if runtime.GOOS != "linux" {
+ return ErrorResult("I2C is only supported on Linux. This tool requires /dev/i2c-* device files.")
+ }
+
+ action, ok := args["action"].(string)
+ if !ok {
+ return ErrorResult("action is required")
+ }
+
+ switch action {
+ case "detect":
+ return t.detect()
+ case "scan":
+ return t.scan(args)
+ case "read":
+ return t.readDevice(args)
+ case "write":
+ return t.writeDevice(args)
+ default:
+ return ErrorResult(fmt.Sprintf("unknown action: %s (valid: detect, scan, read, write)", action))
+ }
+}
+
+// detect lists available I2C buses by globbing /dev/i2c-*
+func (t *I2CTool) detect() *ToolResult {
+ matches, err := filepath.Glob("/dev/i2c-*")
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to scan for I2C buses: %v", err))
+ }
+
+ if len(matches) == 0 {
+ return SilentResult("No I2C buses found. You may need to:\n1. Load the i2c-dev module: modprobe i2c-dev\n2. Check that I2C is enabled in device tree\n3. Configure pinmux for your board (see hardware skill)")
+ }
+
+ type busInfo struct {
+ Path string `json:"path"`
+ Bus string `json:"bus"`
+ }
+
+ buses := make([]busInfo, 0, len(matches))
+ re := regexp.MustCompile(`/dev/i2c-(\d+)`)
+ for _, m := range matches {
+ if sub := re.FindStringSubmatch(m); sub != nil {
+ buses = append(buses, busInfo{Path: m, Bus: sub[1]})
+ }
+ }
+
+ result, _ := json.MarshalIndent(buses, "", " ")
+ return SilentResult(fmt.Sprintf("Found %d I2C bus(es):\n%s", len(buses), string(result)))
+}
+
+// isValidBusID checks that a bus identifier is a simple number (prevents path injection)
+func isValidBusID(id string) bool {
+ matched, _ := regexp.MatchString(`^\d+$`, id)
+ return matched
+}
+
+// parseI2CAddress extracts and validates an I2C address from args
+func parseI2CAddress(args map[string]interface{}) (int, *ToolResult) {
+ addrFloat, ok := args["address"].(float64)
+ if !ok {
+ return 0, ErrorResult("address is required (e.g. 0x38 for AHT20)")
+ }
+ addr := int(addrFloat)
+ if addr < 0x03 || addr > 0x77 {
+ return 0, ErrorResult("address must be in valid 7-bit range (0x03-0x77)")
+ }
+ return addr, nil
+}
+
+// parseI2CBus extracts and validates an I2C bus from args
+func parseI2CBus(args map[string]interface{}) (string, *ToolResult) {
+ bus, ok := args["bus"].(string)
+ if !ok || bus == "" {
+ return "", ErrorResult("bus is required (e.g. \"1\" for /dev/i2c-1)")
+ }
+ if !isValidBusID(bus) {
+ return "", ErrorResult("invalid bus identifier: must be a number (e.g. \"1\")")
+ }
+ return bus, nil
+}
diff --git a/pkg/tools/i2c_linux.go b/pkg/tools/i2c_linux.go
new file mode 100644
index 0000000..294f7ec
--- /dev/null
+++ b/pkg/tools/i2c_linux.go
@@ -0,0 +1,282 @@
+package tools
+
+import (
+ "encoding/json"
+ "fmt"
+ "syscall"
+ "unsafe"
+)
+
+// I2C ioctl constants from Linux kernel headers (, )
+const (
+ i2cSlave = 0x0703 // Set slave address (fails if in use by driver)
+ i2cFuncs = 0x0705 // Query adapter functionality bitmask
+ i2cSmbus = 0x0720 // Perform SMBus transaction
+
+ // I2C_FUNC capability bits
+ i2cFuncSmbusQuick = 0x00010000
+ i2cFuncSmbusReadByte = 0x00020000
+
+ // SMBus transaction types
+ i2cSmbusRead = 0
+ i2cSmbusWrite = 1
+
+ // SMBus protocol sizes
+ i2cSmbusQuick = 0
+ i2cSmbusByte = 1
+)
+
+// i2cSmbusData matches the kernel union i2c_smbus_data (34 bytes max).
+// For quick and byte transactions only the first byte is used (if at all).
+type i2cSmbusData [34]byte
+
+// i2cSmbusArgs matches the kernel struct i2c_smbus_ioctl_data.
+type i2cSmbusArgs struct {
+ readWrite uint8
+ command uint8
+ size uint32
+ data *i2cSmbusData
+}
+
+// smbusProbe performs a single SMBus probe at the given address.
+// Uses SMBus Quick Write (safest) or falls back to SMBus Read Byte for
+// EEPROM address ranges where quick write can corrupt AT24RF08 chips.
+// This matches i2cdetect's MODE_AUTO behavior.
+func smbusProbe(fd int, addr int, hasQuick bool) bool {
+ // EEPROM ranges: use read byte (quick write can corrupt AT24RF08)
+ useReadByte := (addr >= 0x30 && addr <= 0x37) || (addr >= 0x50 && addr <= 0x5F)
+
+ if !useReadByte && hasQuick {
+ // SMBus Quick Write: [START] [ADDR|W] [ACK/NACK] [STOP]
+ // Safest probe — no data transferred
+ args := i2cSmbusArgs{
+ readWrite: i2cSmbusWrite,
+ command: 0,
+ size: i2cSmbusQuick,
+ data: nil,
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args)))
+ return errno == 0
+ }
+
+ // SMBus Read Byte: [START] [ADDR|R] [ACK/NACK] [DATA] [STOP]
+ var data i2cSmbusData
+ args := i2cSmbusArgs{
+ readWrite: i2cSmbusRead,
+ command: 0,
+ size: i2cSmbusByte,
+ data: &data,
+ }
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSmbus, uintptr(unsafe.Pointer(&args)))
+ return errno == 0
+}
+
+// scan probes valid 7-bit addresses on a bus for connected devices.
+// Uses the same hybrid probe strategy as i2cdetect's MODE_AUTO:
+// SMBus Quick Write for most addresses, SMBus Read Byte for EEPROM ranges.
+func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
+ bus, errResult := parseI2CBus(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ devPath := fmt.Sprintf("/dev/i2c-%s", bus)
+ fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and i2c-dev module)", devPath, err))
+ }
+ defer syscall.Close(fd)
+
+ // Query adapter capabilities to determine available probe methods.
+ // I2C_FUNCS writes an unsigned long, which is word-sized on Linux.
+ var funcs uintptr
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cFuncs, uintptr(unsafe.Pointer(&funcs)))
+ if errno != 0 {
+ return ErrorResult(fmt.Sprintf("failed to query I2C adapter capabilities on %s: %v", devPath, errno))
+ }
+
+ hasQuick := funcs&i2cFuncSmbusQuick != 0
+ hasReadByte := funcs&i2cFuncSmbusReadByte != 0
+
+ if !hasQuick && !hasReadByte {
+ return ErrorResult(fmt.Sprintf("I2C adapter %s supports neither SMBus Quick nor Read Byte — cannot probe safely", devPath))
+ }
+
+ type deviceEntry struct {
+ Address string `json:"address"`
+ Status string `json:"status,omitempty"`
+ }
+
+ var found []deviceEntry
+ // Scan 0x08-0x77, skipping I2C reserved addresses 0x00-0x07
+ for addr := 0x08; addr <= 0x77; addr++ {
+ // Set slave address — EBUSY means a kernel driver owns this address
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
+ if errno != 0 {
+ if errno == syscall.EBUSY {
+ found = append(found, deviceEntry{
+ Address: fmt.Sprintf("0x%02x", addr),
+ Status: "busy (in use by kernel driver)",
+ })
+ }
+ continue
+ }
+
+ if smbusProbe(fd, addr, hasQuick) {
+ found = append(found, deviceEntry{
+ Address: fmt.Sprintf("0x%02x", addr),
+ })
+ }
+ }
+
+ if len(found) == 0 {
+ return SilentResult(fmt.Sprintf("No devices found on %s. Check wiring and pull-up resistors.", devPath))
+ }
+
+ result, _ := json.MarshalIndent(map[string]interface{}{
+ "bus": devPath,
+ "devices": found,
+ "count": len(found),
+ }, "", " ")
+ return SilentResult(fmt.Sprintf("Scan of %s:\n%s", devPath, string(result)))
+}
+
+// readDevice reads bytes from an I2C device, optionally at a specific register
+func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
+ bus, errResult := parseI2CBus(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ addr, errResult := parseI2CAddress(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ length := 1
+ if l, ok := args["length"].(float64); ok {
+ length = int(l)
+ }
+ if length < 1 || length > 256 {
+ return ErrorResult("length must be between 1 and 256")
+ }
+
+ devPath := fmt.Sprintf("/dev/i2c-%s", bus)
+ fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err))
+ }
+ defer syscall.Close(fd)
+
+ // Set slave address
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
+ if errno != 0 {
+ return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno))
+ }
+
+ // If register is specified, write it first
+ if regFloat, ok := args["register"].(float64); ok {
+ reg := int(regFloat)
+ if reg < 0 || reg > 255 {
+ return ErrorResult("register must be between 0x00 and 0xFF")
+ }
+ _, err := syscall.Write(fd, []byte{byte(reg)})
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to write register 0x%02x: %v", reg, err))
+ }
+ }
+
+ // Read data
+ buf := make([]byte, length)
+ n, err := syscall.Read(fd, buf)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to read from device 0x%02x: %v", addr, err))
+ }
+
+ // Format as hex bytes
+ hexBytes := make([]string, n)
+ intBytes := make([]int, n)
+ for i := 0; i < n; i++ {
+ hexBytes[i] = fmt.Sprintf("0x%02x", buf[i])
+ intBytes[i] = int(buf[i])
+ }
+
+ result, _ := json.MarshalIndent(map[string]interface{}{
+ "bus": devPath,
+ "address": fmt.Sprintf("0x%02x", addr),
+ "bytes": intBytes,
+ "hex": hexBytes,
+ "length": n,
+ }, "", " ")
+ return SilentResult(string(result))
+}
+
+// writeDevice writes bytes to an I2C device, optionally at a specific register
+func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult {
+ confirm, _ := args["confirm"].(bool)
+ if !confirm {
+ return ErrorResult("write operations require confirm: true. Please confirm with the user before writing to I2C devices, as incorrect writes can misconfigure hardware.")
+ }
+
+ bus, errResult := parseI2CBus(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ addr, errResult := parseI2CAddress(args)
+ if errResult != nil {
+ return errResult
+ }
+
+ dataRaw, ok := args["data"].([]interface{})
+ if !ok || len(dataRaw) == 0 {
+ return ErrorResult("data is required for write (array of byte values 0-255)")
+ }
+ if len(dataRaw) > 256 {
+ return ErrorResult("data too long: maximum 256 bytes per I2C transaction")
+ }
+
+ data := make([]byte, 0, len(dataRaw)+1)
+
+ // If register is specified, prepend it to the data
+ if regFloat, ok := args["register"].(float64); ok {
+ reg := int(regFloat)
+ if reg < 0 || reg > 255 {
+ return ErrorResult("register must be between 0x00 and 0xFF")
+ }
+ data = append(data, byte(reg))
+ }
+
+ for i, v := range dataRaw {
+ f, ok := v.(float64)
+ if !ok {
+ return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i))
+ }
+ b := int(f)
+ if b < 0 || b > 255 {
+ return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b))
+ }
+ data = append(data, byte(b))
+ }
+
+ devPath := fmt.Sprintf("/dev/i2c-%s", bus)
+ fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to open %s: %v", devPath, err))
+ }
+ defer syscall.Close(fd)
+
+ // Set slave address
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), i2cSlave, uintptr(addr))
+ if errno != 0 {
+ return ErrorResult(fmt.Sprintf("failed to set I2C address 0x%02x: %v", addr, errno))
+ }
+
+ // Write data
+ n, err := syscall.Write(fd, data)
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to write to device 0x%02x: %v", addr, err))
+ }
+
+ return SilentResult(fmt.Sprintf("Wrote %d byte(s) to device 0x%02x on %s", n, addr, devPath))
+}
diff --git a/pkg/tools/i2c_other.go b/pkg/tools/i2c_other.go
new file mode 100644
index 0000000..d1d5813
--- /dev/null
+++ b/pkg/tools/i2c_other.go
@@ -0,0 +1,18 @@
+//go:build !linux
+
+package tools
+
+// scan is a stub for non-Linux platforms.
+func (t *I2CTool) scan(args map[string]interface{}) *ToolResult {
+ return ErrorResult("I2C is only supported on Linux")
+}
+
+// readDevice is a stub for non-Linux platforms.
+func (t *I2CTool) readDevice(args map[string]interface{}) *ToolResult {
+ return ErrorResult("I2C is only supported on Linux")
+}
+
+// writeDevice is a stub for non-Linux platforms.
+func (t *I2CTool) writeDevice(args map[string]interface{}) *ToolResult {
+ return ErrorResult("I2C is only supported on Linux")
+}
diff --git a/pkg/tools/spi.go b/pkg/tools/spi.go
new file mode 100644
index 0000000..4805d6a
--- /dev/null
+++ b/pkg/tools/spi.go
@@ -0,0 +1,156 @@
+package tools
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "path/filepath"
+ "regexp"
+ "runtime"
+)
+
+// SPITool provides SPI bus interaction for high-speed peripheral communication.
+type SPITool struct{}
+
+func NewSPITool() *SPITool {
+ return &SPITool{}
+}
+
+func (t *SPITool) Name() string {
+ return "spi"
+}
+
+func (t *SPITool) Description() string {
+ return "Interact with SPI bus devices for high-speed peripheral communication. Actions: list (find SPI devices), transfer (full-duplex send/receive), read (receive bytes). Linux only."
+}
+
+func (t *SPITool) Parameters() map[string]interface{} {
+ return map[string]interface{}{
+ "type": "object",
+ "properties": map[string]interface{}{
+ "action": map[string]interface{}{
+ "type": "string",
+ "enum": []string{"list", "transfer", "read"},
+ "description": "Action to perform: list (find available SPI devices), transfer (full-duplex send/receive), read (receive bytes by sending zeros)",
+ },
+ "device": map[string]interface{}{
+ "type": "string",
+ "description": "SPI device identifier (e.g. \"2.0\" for /dev/spidev2.0). Required for transfer/read.",
+ },
+ "speed": map[string]interface{}{
+ "type": "integer",
+ "description": "SPI clock speed in Hz. Default: 1000000 (1 MHz).",
+ },
+ "mode": map[string]interface{}{
+ "type": "integer",
+ "description": "SPI mode (0-3). Default: 0. Mode sets CPOL and CPHA: 0=0,0 1=0,1 2=1,0 3=1,1.",
+ },
+ "bits": map[string]interface{}{
+ "type": "integer",
+ "description": "Bits per word. Default: 8.",
+ },
+ "data": map[string]interface{}{
+ "type": "array",
+ "items": map[string]interface{}{"type": "integer"},
+ "description": "Bytes to send (0-255 each). Required for transfer action.",
+ },
+ "length": map[string]interface{}{
+ "type": "integer",
+ "description": "Number of bytes to read (1-4096). Required for read action.",
+ },
+ "confirm": map[string]interface{}{
+ "type": "boolean",
+ "description": "Must be true for transfer operations. Safety guard to prevent accidental writes.",
+ },
+ },
+ "required": []string{"action"},
+ }
+}
+
+func (t *SPITool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
+ if runtime.GOOS != "linux" {
+ return ErrorResult("SPI is only supported on Linux. This tool requires /dev/spidev* device files.")
+ }
+
+ action, ok := args["action"].(string)
+ if !ok {
+ return ErrorResult("action is required")
+ }
+
+ switch action {
+ case "list":
+ return t.list()
+ case "transfer":
+ return t.transfer(args)
+ case "read":
+ return t.readDevice(args)
+ default:
+ return ErrorResult(fmt.Sprintf("unknown action: %s (valid: list, transfer, read)", action))
+ }
+}
+
+// list finds available SPI devices by globbing /dev/spidev*
+func (t *SPITool) list() *ToolResult {
+ matches, err := filepath.Glob("/dev/spidev*")
+ if err != nil {
+ return ErrorResult(fmt.Sprintf("failed to scan for SPI devices: %v", err))
+ }
+
+ if len(matches) == 0 {
+ return SilentResult("No SPI devices found. You may need to:\n1. Enable SPI in device tree\n2. Configure pinmux for your board (see hardware skill)\n3. Check that spidev module is loaded")
+ }
+
+ type devInfo struct {
+ Path string `json:"path"`
+ Device string `json:"device"`
+ }
+
+ devices := make([]devInfo, 0, len(matches))
+ re := regexp.MustCompile(`/dev/spidev(\d+\.\d+)`)
+ for _, m := range matches {
+ if sub := re.FindStringSubmatch(m); sub != nil {
+ devices = append(devices, devInfo{Path: m, Device: sub[1]})
+ }
+ }
+
+ result, _ := json.MarshalIndent(devices, "", " ")
+ return SilentResult(fmt.Sprintf("Found %d SPI device(s):\n%s", len(devices), string(result)))
+}
+
+// parseSPIArgs extracts and validates common SPI parameters
+func parseSPIArgs(args map[string]interface{}) (device string, speed uint32, mode uint8, bits uint8, errMsg string) {
+ dev, ok := args["device"].(string)
+ if !ok || dev == "" {
+ return "", 0, 0, 0, "device is required (e.g. \"2.0\" for /dev/spidev2.0)"
+ }
+ matched, _ := regexp.MatchString(`^\d+\.\d+$`, dev)
+ if !matched {
+ return "", 0, 0, 0, "invalid device identifier: must be in format \"X.Y\" (e.g. \"2.0\")"
+ }
+
+ speed = 1000000 // default 1 MHz
+ if s, ok := args["speed"].(float64); ok {
+ if s < 1 || s > 125000000 {
+ return "", 0, 0, 0, "speed must be between 1 Hz and 125 MHz"
+ }
+ speed = uint32(s)
+ }
+
+ mode = 0
+ if m, ok := args["mode"].(float64); ok {
+ if int(m) < 0 || int(m) > 3 {
+ return "", 0, 0, 0, "mode must be 0-3"
+ }
+ mode = uint8(m)
+ }
+
+ bits = 8
+ if b, ok := args["bits"].(float64); ok {
+ if int(b) < 1 || int(b) > 32 {
+ return "", 0, 0, 0, "bits must be between 1 and 32"
+ }
+ bits = uint8(b)
+ }
+
+ return dev, speed, mode, bits, ""
+}
diff --git a/pkg/tools/spi_linux.go b/pkg/tools/spi_linux.go
new file mode 100644
index 0000000..12b6960
--- /dev/null
+++ b/pkg/tools/spi_linux.go
@@ -0,0 +1,196 @@
+package tools
+
+import (
+ "encoding/json"
+ "fmt"
+ "runtime"
+ "syscall"
+ "unsafe"
+)
+
+// SPI ioctl constants from Linux kernel headers.
+// Calculated from _IOW('k', nr, size) macro:
+//
+// direction(1)<<30 | size<<16 | type(0x6B)<<8 | nr
+const (
+ spiIocWrMode = 0x40016B01 // _IOW('k', 1, __u8)
+ spiIocWrBitsPerWord = 0x40016B03 // _IOW('k', 3, __u8)
+ spiIocWrMaxSpeedHz = 0x40046B04 // _IOW('k', 4, __u32)
+ spiIocMessage1 = 0x40206B00 // _IOW('k', 0, struct spi_ioc_transfer) — 32 bytes
+)
+
+// spiTransfer matches Linux kernel struct spi_ioc_transfer (32 bytes on all architectures).
+type spiTransfer struct {
+ txBuf uint64
+ rxBuf uint64
+ length uint32
+ speedHz uint32
+ delayUsecs uint16
+ bitsPerWord uint8
+ csChange uint8
+ txNbits uint8
+ rxNbits uint8
+ wordDelay uint8
+ pad uint8
+}
+
+// configureSPI opens an SPI device and sets mode, bits per word, and speed
+func configureSPI(devPath string, mode uint8, bits uint8, speed uint32) (int, *ToolResult) {
+ fd, err := syscall.Open(devPath, syscall.O_RDWR, 0)
+ if err != nil {
+ return -1, ErrorResult(fmt.Sprintf("failed to open %s: %v (check permissions and spidev module)", devPath, err))
+ }
+
+ // Set SPI mode
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMode, uintptr(unsafe.Pointer(&mode)))
+ if errno != 0 {
+ syscall.Close(fd)
+ return -1, ErrorResult(fmt.Sprintf("failed to set SPI mode %d: %v", mode, errno))
+ }
+
+ // Set bits per word
+ _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrBitsPerWord, uintptr(unsafe.Pointer(&bits)))
+ if errno != 0 {
+ syscall.Close(fd)
+ return -1, ErrorResult(fmt.Sprintf("failed to set bits per word %d: %v", bits, errno))
+ }
+
+ // Set max speed
+ _, _, errno = syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocWrMaxSpeedHz, uintptr(unsafe.Pointer(&speed)))
+ if errno != 0 {
+ syscall.Close(fd)
+ return -1, ErrorResult(fmt.Sprintf("failed to set SPI speed %d Hz: %v", speed, errno))
+ }
+
+ return fd, nil
+}
+
+// transfer performs a full-duplex SPI transfer
+func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
+ confirm, _ := args["confirm"].(bool)
+ if !confirm {
+ return ErrorResult("transfer operations require confirm: true. Please confirm with the user before sending data to SPI devices.")
+ }
+
+ dev, speed, mode, bits, errMsg := parseSPIArgs(args)
+ if errMsg != "" {
+ return ErrorResult(errMsg)
+ }
+
+ dataRaw, ok := args["data"].([]interface{})
+ if !ok || len(dataRaw) == 0 {
+ return ErrorResult("data is required for transfer (array of byte values 0-255)")
+ }
+ if len(dataRaw) > 4096 {
+ return ErrorResult("data too long: maximum 4096 bytes per SPI transfer")
+ }
+
+ txBuf := make([]byte, len(dataRaw))
+ for i, v := range dataRaw {
+ f, ok := v.(float64)
+ if !ok {
+ return ErrorResult(fmt.Sprintf("data[%d] is not a valid byte value", i))
+ }
+ b := int(f)
+ if b < 0 || b > 255 {
+ return ErrorResult(fmt.Sprintf("data[%d] = %d is out of byte range (0-255)", i, b))
+ }
+ txBuf[i] = byte(b)
+ }
+
+ devPath := fmt.Sprintf("/dev/spidev%s", dev)
+ fd, errResult := configureSPI(devPath, mode, bits, speed)
+ if errResult != nil {
+ return errResult
+ }
+ defer syscall.Close(fd)
+
+ rxBuf := make([]byte, len(txBuf))
+
+ xfer := spiTransfer{
+ txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))),
+ rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))),
+ length: uint32(len(txBuf)),
+ speedHz: speed,
+ bitsPerWord: bits,
+ }
+
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer)))
+ runtime.KeepAlive(txBuf)
+ runtime.KeepAlive(rxBuf)
+ if errno != 0 {
+ return ErrorResult(fmt.Sprintf("SPI transfer failed: %v", errno))
+ }
+
+ // Format received bytes
+ hexBytes := make([]string, len(rxBuf))
+ intBytes := make([]int, len(rxBuf))
+ for i, b := range rxBuf {
+ hexBytes[i] = fmt.Sprintf("0x%02x", b)
+ intBytes[i] = int(b)
+ }
+
+ result, _ := json.MarshalIndent(map[string]interface{}{
+ "device": devPath,
+ "sent": len(txBuf),
+ "received": intBytes,
+ "hex": hexBytes,
+ }, "", " ")
+ return SilentResult(string(result))
+}
+
+// readDevice reads bytes from SPI by sending zeros (read-only, no confirm needed)
+func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult {
+ dev, speed, mode, bits, errMsg := parseSPIArgs(args)
+ if errMsg != "" {
+ return ErrorResult(errMsg)
+ }
+
+ length := 0
+ if l, ok := args["length"].(float64); ok {
+ length = int(l)
+ }
+ if length < 1 || length > 4096 {
+ return ErrorResult("length is required for read (1-4096)")
+ }
+
+ devPath := fmt.Sprintf("/dev/spidev%s", dev)
+ fd, errResult := configureSPI(devPath, mode, bits, speed)
+ if errResult != nil {
+ return errResult
+ }
+ defer syscall.Close(fd)
+
+ txBuf := make([]byte, length) // zeros
+ rxBuf := make([]byte, length)
+
+ xfer := spiTransfer{
+ txBuf: uint64(uintptr(unsafe.Pointer(&txBuf[0]))),
+ rxBuf: uint64(uintptr(unsafe.Pointer(&rxBuf[0]))),
+ length: uint32(length),
+ speedHz: speed,
+ bitsPerWord: bits,
+ }
+
+ _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), spiIocMessage1, uintptr(unsafe.Pointer(&xfer)))
+ runtime.KeepAlive(txBuf)
+ runtime.KeepAlive(rxBuf)
+ if errno != 0 {
+ return ErrorResult(fmt.Sprintf("SPI read failed: %v", errno))
+ }
+
+ hexBytes := make([]string, len(rxBuf))
+ intBytes := make([]int, len(rxBuf))
+ for i, b := range rxBuf {
+ hexBytes[i] = fmt.Sprintf("0x%02x", b)
+ intBytes[i] = int(b)
+ }
+
+ result, _ := json.MarshalIndent(map[string]interface{}{
+ "device": devPath,
+ "bytes": intBytes,
+ "hex": hexBytes,
+ "length": len(rxBuf),
+ }, "", " ")
+ return SilentResult(string(result))
+}
diff --git a/pkg/tools/spi_other.go b/pkg/tools/spi_other.go
new file mode 100644
index 0000000..6dfc86f
--- /dev/null
+++ b/pkg/tools/spi_other.go
@@ -0,0 +1,13 @@
+//go:build !linux
+
+package tools
+
+// transfer is a stub for non-Linux platforms.
+func (t *SPITool) transfer(args map[string]interface{}) *ToolResult {
+ return ErrorResult("SPI is only supported on Linux")
+}
+
+// readDevice is a stub for non-Linux platforms.
+func (t *SPITool) readDevice(args map[string]interface{}) *ToolResult {
+ return ErrorResult("SPI is only supported on Linux")
+}
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index 6fc89c9..ccd9958 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -114,7 +114,7 @@ func (p *DuckDuckGoSearchProvider) Search(ctx context.Context, query string, cou
func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query string) (string, error) {
// Simple regex based extraction for DDG HTML
// Strategy: Find all result containers or key anchors directly
-
+
// Try finding the result links directly first, as they are the most critical
// Pattern: Title
// The previous regex was a bit strict. Let's make it more flexible for attributes order/content
@@ -133,14 +133,14 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
// But simple global search for snippets might mismatch order.
// Since we only have the raw HTML string, let's just extract snippets globally and assume order matches (risky but simple for regex)
// Or better: Let's assume the snippet follows the link in the HTML
-
+
// A better regex approach: iterate through text and find matches in order
// But for now, let's grab all snippets too
reSnippet := regexp.MustCompile(`([\s\S]*?)`)
snippetMatches := reSnippet.FindAllStringSubmatch(html, count+5)
maxItems := min(len(matches), count)
-
+
for i := 0; i < maxItems; i++ {
urlStr := matches[i][1]
title := stripTags(matches[i][2])
@@ -157,7 +157,7 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
}
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, urlStr))
-
+
// Attempt to attach snippet if available and index aligns
if i < len(snippetMatches) {
snippet := stripTags(snippetMatches[i][1])
@@ -171,13 +171,6 @@ func (p *DuckDuckGoSearchProvider) extractResults(html string, count int, query
return strings.Join(lines, "\n"), nil
}
-func min(a, b int) int {
- if a < b {
- return a
- }
- return b
-}
-
func stripTags(content string) string {
re := regexp.MustCompile(`<[^>]+>`)
return re.ReplaceAllString(content, "")
diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go
index 30bc7d9..988eada 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/web_test.go
@@ -173,30 +173,19 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
}
}
-// TestWebTool_WebSearch_NoApiKey verifies error handling when API key is missing
+// TestWebTool_WebSearch_NoApiKey verifies that nil is returned when no provider is configured
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
- tool := NewWebSearchTool("", 5)
- ctx := context.Background()
- args := map[string]interface{}{
- "query": "test",
- }
+ tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "", BraveMaxResults: 5})
- result := tool.Execute(ctx, args)
-
- // Should return error result
- if !result.IsError {
- t.Errorf("Expected error when API key is missing")
- }
-
- // Should mention missing API key
- if !strings.Contains(result.ForLLM, "BRAVE_API_KEY") && !strings.Contains(result.ForUser, "BRAVE_API_KEY") {
- t.Errorf("Expected API key error message, got ForLLM: %s", result.ForLLM)
+ // Should return nil when no provider is enabled
+ if tool != nil {
+ t.Errorf("Expected nil when no search provider is configured")
}
}
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
- tool := NewWebSearchTool("test-key", 5)
+ tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "test-key", BraveMaxResults: 5, BraveEnabled: true})
ctx := context.Background()
args := map[string]interface{}{}
diff --git a/workspace/AGENT.md b/workspace/AGENT.md
new file mode 100644
index 0000000..5f5fa64
--- /dev/null
+++ b/workspace/AGENT.md
@@ -0,0 +1,12 @@
+# Agent Instructions
+
+You are a helpful AI assistant. Be concise, accurate, and friendly.
+
+## Guidelines
+
+- Always explain what you're doing before taking actions
+- Ask for clarification when request is ambiguous
+- Use tools to help accomplish tasks
+- Remember important information in your memory files
+- Be proactive and helpful
+- Learn from user feedback
\ No newline at end of file
diff --git a/workspace/IDENTITY.md b/workspace/IDENTITY.md
new file mode 100644
index 0000000..dabb0e1
--- /dev/null
+++ b/workspace/IDENTITY.md
@@ -0,0 +1,56 @@
+# Identity
+
+## Name
+PicoClaw 🦞
+
+## Description
+Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
+
+## Version
+0.1.0
+
+## Purpose
+- Provide intelligent AI assistance with minimal resource usage
+- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)
+- Enable easy customization through skills system
+- Run on minimal hardware ($10 boards, <10MB RAM)
+
+## Capabilities
+
+- Web search and content fetching
+- File system operations (read, write, edit)
+- Shell command execution
+- Multi-channel messaging (Telegram, WhatsApp, Feishu)
+- Skill-based extensibility
+- Memory and context management
+
+## Philosophy
+
+- Simplicity over complexity
+- Performance over features
+- User control and privacy
+- Transparent operation
+- Community-driven development
+
+## Goals
+
+- Provide a fast, lightweight AI assistant
+- Support offline-first operation where possible
+- Enable easy customization and extension
+- Maintain high quality responses
+- Run efficiently on constrained hardware
+
+## License
+MIT License - Free and open source
+
+## Repository
+https://github.com/sipeed/picoclaw
+
+## Contact
+Issues: https://github.com/sipeed/picoclaw/issues
+Discussions: https://github.com/sipeed/picoclaw/discussions
+
+---
+
+"Every bit helps, every bit matters."
+- Picoclaw
\ No newline at end of file
diff --git a/workspace/SOUL.md b/workspace/SOUL.md
new file mode 100644
index 0000000..0be8834
--- /dev/null
+++ b/workspace/SOUL.md
@@ -0,0 +1,17 @@
+# Soul
+
+I am picoclaw, a lightweight AI assistant powered by AI.
+
+## Personality
+
+- Helpful and friendly
+- Concise and to the point
+- Curious and eager to learn
+- Honest and transparent
+
+## Values
+
+- Accuracy over speed
+- User privacy and safety
+- Transparency in actions
+- Continuous improvement
\ No newline at end of file
diff --git a/workspace/USER.md b/workspace/USER.md
new file mode 100644
index 0000000..91398a0
--- /dev/null
+++ b/workspace/USER.md
@@ -0,0 +1,21 @@
+# User
+
+Information about user goes here.
+
+## Preferences
+
+- Communication style: (casual/formal)
+- Timezone: (your timezone)
+- Language: (your preferred language)
+
+## Personal Information
+
+- Name: (optional)
+- Location: (optional)
+- Occupation: (optional)
+
+## Learning Goals
+
+- What the user wants to learn from AI
+- Preferred interaction style
+- Areas of interest
\ No newline at end of file
diff --git a/workspace/memory/MEMORY.md b/workspace/memory/MEMORY.md
new file mode 100644
index 0000000..265271d
--- /dev/null
+++ b/workspace/memory/MEMORY.md
@@ -0,0 +1,21 @@
+# Long-term Memory
+
+This file stores important information that should persist across sessions.
+
+## User Information
+
+(Important facts about user)
+
+## Preferences
+
+(User preferences learned over time)
+
+## Important Notes
+
+(Things to remember)
+
+## Configuration
+
+- Model preferences
+- Channel settings
+- Skills enabled
\ No newline at end of file
diff --git a/skills/github/SKILL.md b/workspace/skills/github/SKILL.md
similarity index 100%
rename from skills/github/SKILL.md
rename to workspace/skills/github/SKILL.md
diff --git a/workspace/skills/hardware/SKILL.md b/workspace/skills/hardware/SKILL.md
new file mode 100644
index 0000000..e89d1b6
--- /dev/null
+++ b/workspace/skills/hardware/SKILL.md
@@ -0,0 +1,64 @@
+---
+name: hardware
+description: Read and control I2C and SPI peripherals on Sipeed boards (LicheeRV Nano, MaixCAM, NanoKVM).
+homepage: https://wiki.sipeed.com/hardware/en/lichee/RV_Nano/1_intro.html
+metadata: {"nanobot":{"emoji":"🔧","requires":{"tools":["i2c","spi"]}}}
+---
+
+# Hardware (I2C / SPI)
+
+Use the `i2c` and `spi` tools to interact with sensors, displays, and other peripherals connected to the board.
+
+## Quick Start
+
+```
+# 1. Find available buses
+i2c detect
+
+# 2. Scan for connected devices
+i2c scan (bus: "1")
+
+# 3. Read from a sensor (e.g. AHT20 temperature/humidity)
+i2c read (bus: "1", address: 0x38, register: 0xAC, length: 6)
+
+# 4. SPI devices
+spi list
+spi read (device: "2.0", length: 4)
+```
+
+## Before You Start — Pinmux Setup
+
+Most I2C/SPI pins are shared with WiFi on Sipeed boards. You must configure pinmux before use.
+
+See `references/board-pinout.md` for board-specific commands.
+
+**Common steps:**
+1. Stop WiFi if using shared pins: `/etc/init.d/S30wifi stop`
+2. Load i2c-dev module: `modprobe i2c-dev`
+3. Configure pinmux with `devmem` (board-specific)
+4. Verify with `i2c detect` and `i2c scan`
+
+## Safety
+
+- **Write operations** require `confirm: true` — always confirm with the user first
+- I2C addresses are validated to 7-bit range (0x03-0x77)
+- SPI modes are validated (0-3 only)
+- Maximum per-transaction: 256 bytes (I2C), 4096 bytes (SPI)
+
+## Common Devices
+
+See `references/common-devices.md` for register maps and usage of popular sensors:
+AHT20, BME280, SSD1306 OLED, MPU6050 IMU, DS3231 RTC, INA219 power monitor, PCA9685 PWM, and more.
+
+## Troubleshooting
+
+| Problem | Solution |
+|---------|----------|
+| No I2C buses found | `modprobe i2c-dev` and check device tree |
+| Permission denied | Run as root or add user to `i2c` group |
+| No devices on scan | Check wiring, pull-up resistors (4.7k typical), and pinmux |
+| Bus number changed | I2C adapter numbers can shift between boots; use `i2c detect` to find current assignment |
+| WiFi stopped working | I2C-1/SPI-2 share pins with WiFi SDIO; can't use both simultaneously |
+| `devmem` not found | Download separately or use `busybox devmem` |
+| SPI transfer returns all zeros | Check MISO wiring and device power |
+| SPI transfer returns all 0xFF | Device not responding; check CS pin and clock polarity (mode) |
diff --git a/workspace/skills/hardware/references/board-pinout.md b/workspace/skills/hardware/references/board-pinout.md
new file mode 100644
index 0000000..827dd06
--- /dev/null
+++ b/workspace/skills/hardware/references/board-pinout.md
@@ -0,0 +1,131 @@
+# Board Pinout & Pinmux Reference
+
+## LicheeRV Nano (SG2002)
+
+### I2C Buses
+
+| Bus | Pins | Notes |
+|-----|------|-------|
+| I2C-1 | P18 (SCL), P21 (SDA) | **Shared with WiFi SDIO** — must stop WiFi first |
+| I2C-3 | Available on header | Check device tree for pin assignment |
+| I2C-5 | Software (BitBang) | Slower but no pin conflicts |
+
+### SPI Buses
+
+| Bus | Pins | Notes |
+|-----|------|-------|
+| SPI-2 | P18 (CS), P21 (MISO), P22 (MOSI), P23 (SCK) | **Shared with WiFi** — must stop WiFi first |
+| SPI-4 | Software (BitBang) | Slower but no pin conflicts |
+
+### Setup Steps for I2C-1
+
+```bash
+# 1. Stop WiFi (shares pins with I2C-1)
+/etc/init.d/S30wifi stop
+
+# 2. Configure pinmux for I2C-1
+devmem 0x030010D0 b 0x2 # P18 → I2C1_SCL
+devmem 0x030010DC b 0x2 # P21 → I2C1_SDA
+
+# 3. Load i2c-dev module
+modprobe i2c-dev
+
+# 4. Verify
+ls /dev/i2c-*
+```
+
+### Setup Steps for SPI-2
+
+```bash
+# 1. Stop WiFi (shares pins with SPI-2)
+/etc/init.d/S30wifi stop
+
+# 2. Configure pinmux for SPI-2
+devmem 0x030010D0 b 0x1 # P18 → SPI2_CS
+devmem 0x030010DC b 0x1 # P21 → SPI2_MISO
+devmem 0x030010E0 b 0x1 # P22 → SPI2_MOSI
+devmem 0x030010E4 b 0x1 # P23 → SPI2_SCK
+
+# 3. Verify
+ls /dev/spidev*
+```
+
+### Max Tested SPI Speed
+- SPI-2 hardware: tested up to **93 MHz**
+- `spidev_test` is pre-installed on the official image for loopback testing
+
+---
+
+## MaixCAM
+
+### I2C Buses
+
+| Bus | Pins | Notes |
+|-----|------|-------|
+| I2C-1 | Overlaps with WiFi | Not recommended |
+| I2C-3 | Overlaps with WiFi | Not recommended |
+| I2C-5 | A15 (SCL), A27 (SDA) | **Recommended** — software I2C, no conflicts |
+
+### Setup Steps for I2C-5
+
+```bash
+# Configure pins using pinmap utility
+# (MaixCAM uses a pinmap tool instead of devmem)
+# Refer to: https://wiki.sipeed.com/hardware/en/maixcam/gpio.html
+
+# Load i2c-dev
+modprobe i2c-dev
+
+# Verify
+ls /dev/i2c-*
+```
+
+---
+
+## MaixCAM2
+
+### I2C Buses
+
+| Bus | Pins | Notes |
+|-----|------|-------|
+| I2C-6 | A1 (SCL), A0 (SDA) | Available on header |
+| I2C-7 | Available | Check device tree |
+
+### Setup Steps
+
+```bash
+# Configure pinmap for I2C-6
+# A1 → I2C6_SCL, A0 → I2C6_SDA
+# Refer to MaixCAM2 documentation for pinmap commands
+
+modprobe i2c-dev
+ls /dev/i2c-*
+```
+
+---
+
+## NanoKVM
+
+Uses the same SG2002 SoC as LicheeRV Nano. GPIO and I2C access follows the same pinmux procedure. Refer to the LicheeRV Nano section above.
+
+Check NanoKVM-specific pin headers for available I2C/SPI lines:
+- https://wiki.sipeed.com/hardware/en/kvm/NanoKVM/introduction.html
+
+---
+
+## Common Issues
+
+### devmem not found
+The `devmem` utility may not be in the default image. Options:
+- Use `busybox devmem` if busybox is installed
+- Download devmem from the Sipeed package repository
+- Cross-compile from source (single C file)
+
+### Dynamic bus numbering
+I2C adapter numbers can change between boots depending on driver load order. Always use `i2c detect` to find current bus assignments rather than hardcoding bus numbers.
+
+### Permissions
+`/dev/i2c-*` and `/dev/spidev*` typically require root access. Options:
+- Run picoclaw as root
+- Add user to `i2c` and `spi` groups
+- Create udev rules: `SUBSYSTEM=="i2c-dev", MODE="0666"`
diff --git a/workspace/skills/hardware/references/common-devices.md b/workspace/skills/hardware/references/common-devices.md
new file mode 100644
index 0000000..715e8ab
--- /dev/null
+++ b/workspace/skills/hardware/references/common-devices.md
@@ -0,0 +1,78 @@
+# Common I2C/SPI Device Reference
+
+## I2C Devices
+
+### AHT20 — Temperature & Humidity
+- **Address:** 0x38
+- **Init:** Write `[0xBE, 0x08, 0x00]` then wait 10ms
+- **Measure:** Write `[0xAC, 0x33, 0x00]`, wait 80ms, read 6 bytes
+- **Parse:** Status=byte[0], Humidity=(byte[1]<<12|byte[2]<<4|byte[3]>>4)/2^20*100, Temp=(byte[3]&0x0F<<16|byte[4]<<8|byte[5])/2^20*200-50
+- **Notes:** No register addressing — write command bytes directly (omit `register` param)
+
+### BME280 / BMP280 — Temperature, Humidity, Pressure
+- **Address:** 0x76 or 0x77 (SDO pin selects)
+- **Chip ID register:** 0xD0 → BMP280=0x58, BME280=0x60
+- **Data registers:** 0xF7-0xFE (pressure, temperature, humidity)
+- **Config:** Write 0xF2 (humidity oversampling), 0xF4 (temp/press oversampling + mode), 0xF5 (standby, filter)
+- **Forced measurement:** Write `[0x25]` to register 0xF4, wait 40ms, read 8 bytes from 0xF7
+- **Calibration:** Read 26 bytes from 0x88 and 7 bytes from 0xE1 for compensation formulas
+- **Also available via SPI** (mode 0 or 3)
+
+### SSD1306 — 128x64 OLED Display
+- **Address:** 0x3C (or 0x3D if SA0 high)
+- **Command prefix:** 0x00 (write to register 0x00)
+- **Data prefix:** 0x40 (write to register 0x40)
+- **Init sequence:** `[0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0x20, 0x00, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6, 0xAF]`
+- **Display on:** 0xAF, **Display off:** 0xAE
+- **Also available via SPI** (faster, recommended for animations)
+
+### MPU6050 — 6-axis Accelerometer + Gyroscope
+- **Address:** 0x68 (or 0x69 if AD0 high)
+- **WHO_AM_I:** Register 0x75 → should return 0x68
+- **Wake up:** Write `[0x00]` to register 0x6B (clear sleep bit)
+- **Read accel:** 6 bytes from register 0x3B (XH,XL,YH,YL,ZH,ZL) — signed 16-bit, default ±2g
+- **Read gyro:** 6 bytes from register 0x43 — signed 16-bit, default ±250°/s
+- **Read temp:** 2 bytes from register 0x41 — Temp°C = value/340 + 36.53
+
+### DS3231 — Real-Time Clock
+- **Address:** 0x68
+- **Read time:** 7 bytes from register 0x00 (seconds, minutes, hours, day, date, month, year) — BCD encoded
+- **Set time:** Write 7 BCD bytes to register 0x00
+- **Temperature:** 2 bytes from register 0x11 (signed, 0.25°C resolution)
+- **Status:** Register 0x0F — bit 2 = busy, bit 0 = alarm 1 flag
+
+### INA219 — Current & Power Monitor
+- **Address:** 0x40-0x4F (A0,A1 pin selectable)
+- **Config:** Register 0x00 — set voltage range, gain, ADC resolution
+- **Shunt voltage:** Register 0x01 (signed 16-bit, LSB=10µV)
+- **Bus voltage:** Register 0x02 (bits 15:3, LSB=4mV)
+- **Power:** Register 0x03 (after calibration)
+- **Current:** Register 0x04 (after calibration)
+- **Calibration:** Register 0x05 — set based on shunt resistor value
+
+### PCA9685 — 16-Channel PWM / Servo Controller
+- **Address:** 0x40-0x7F (A0-A5 selectable, default 0x40)
+- **Mode 1:** Register 0x00 — bit 4=sleep, bit 5=auto-increment
+- **Set PWM freq:** Sleep → write prescale to 0xFE → wake. Prescale = round(25MHz / (4096 × freq)) - 1
+- **Channel N on/off:** Registers 0x06+4*N to 0x09+4*N (ON_L, ON_H, OFF_L, OFF_H)
+- **Servo 0°-180°:** ON=0, OFF=150-600 (at 50Hz). Typical: 0°=150, 90°=375, 180°=600
+
+### AT24C256 — 256Kbit EEPROM
+- **Address:** 0x50-0x57 (A0-A2 selectable)
+- **Read:** Write 2-byte address (high, low), then read N bytes
+- **Write:** Write 2-byte address + up to 64 bytes (page write), wait 5ms for write cycle
+- **Page size:** 64 bytes. Writes that cross page boundary wrap around.
+
+## SPI Devices
+
+### MCP3008 — 8-Channel 10-bit ADC
+- **Interface:** SPI mode 0, max 3.6 MHz @ 5V
+- **Read channel N:** Send `[0x01, (0x80 | N<<4), 0x00]`, result in last 10 bits of bytes 1-2
+- **Formula:** value = ((byte[1] & 0x03) << 8) | byte[2]
+- **Voltage:** value × Vref / 1024
+
+### W25Q128 — 128Mbit SPI Flash
+- **Interface:** SPI mode 0 or 3, up to 104 MHz
+- **Read ID:** Send `[0x9F, 0, 0, 0]` → manufacturer + device ID
+- **Read data:** Send `[0x03, addr_high, addr_mid, addr_low]` + N zero bytes
+- **Status:** Send `[0x05, 0]` → bit 0 = BUSY
diff --git a/skills/skill-creator/SKILL.md b/workspace/skills/skill-creator/SKILL.md
similarity index 100%
rename from skills/skill-creator/SKILL.md
rename to workspace/skills/skill-creator/SKILL.md
diff --git a/skills/summarize/SKILL.md b/workspace/skills/summarize/SKILL.md
similarity index 100%
rename from skills/summarize/SKILL.md
rename to workspace/skills/summarize/SKILL.md
diff --git a/skills/tmux/SKILL.md b/workspace/skills/tmux/SKILL.md
similarity index 100%
rename from skills/tmux/SKILL.md
rename to workspace/skills/tmux/SKILL.md
diff --git a/skills/tmux/scripts/find-sessions.sh b/workspace/skills/tmux/scripts/find-sessions.sh
similarity index 100%
rename from skills/tmux/scripts/find-sessions.sh
rename to workspace/skills/tmux/scripts/find-sessions.sh
diff --git a/skills/tmux/scripts/wait-for-text.sh b/workspace/skills/tmux/scripts/wait-for-text.sh
similarity index 100%
rename from skills/tmux/scripts/wait-for-text.sh
rename to workspace/skills/tmux/scripts/wait-for-text.sh
diff --git a/skills/weather/SKILL.md b/workspace/skills/weather/SKILL.md
similarity index 100%
rename from skills/weather/SKILL.md
rename to workspace/skills/weather/SKILL.md