diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
new file mode 100644
index 000000000..4be385b22
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -0,0 +1,28 @@
+---
+name: Bug report
+about: Report a bug or unexpected behavior
+title: "[BUG]"
+labels: bug
+assignees: ''
+
+---
+
+## Quick Summary
+
+## Environment & Tools
+- **PicoClaw Version:** (e.g., v0.1.2 or commit hash)
+- **Go Version:** (e.g., go 1.22)
+- **AI Model & Provider:** (e.g., GPT-4o via OpenAI / DeepSeek via SiliconFlow)
+- **Operating System:** (e.g., Ubuntu 22.04 / macOS / Android Termux)
+- **Channels:** (e.g., Discord, Telegram, Feishu, ...)
+
+## 📸 Steps to Reproduce
+1.
+2.
+3.
+
+## ❌ Actual Behavior
+
+## ✅ Expected Behavior
+
+## 💬 Additional Context
diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
new file mode 100644
index 000000000..d3df0e79c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -0,0 +1,23 @@
+---
+name: Feature request
+about: Suggest a new idea or improvement
+title: "[Feature]"
+labels: enhancement
+assignees: ''
+
+---
+
+## 🎯 The Goal / Use Case
+
+## 💡 Proposed Solution
+
+## 🛠 Potential Implementation (Optional)
+
+## 🚦 Impact & Roadmap Alignment
+- [ ] This is a Core Feature
+- [ ] This is a Nice-to-Have / Enhancement
+- [ ] This aligns with the current Roadmap
+
+## 🔄 Alternatives Considered
+
+## 💬 Additional Context
diff --git a/.github/ISSUE_TEMPLATE/general-task---todo.md b/.github/ISSUE_TEMPLATE/general-task---todo.md
new file mode 100644
index 000000000..eab70c030
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/general-task---todo.md
@@ -0,0 +1,26 @@
+---
+name: General Task / Todo
+about: A specific piece of work like doc, refactoring, or maintenance.
+title: "[Task]"
+labels: ''
+assignees: ''
+
+---
+
+## 📝 Objective
+
+## 📋 To-Do List
+- [ ] Step 1
+- [ ] Step 2
+- [ ] Step 3
+
+## 🎯 Definition of Done (Acceptance Criteria)
+- [ ] Documentation is updated in the README/docs folder.
+- [ ] Code follows project linting standards.
+- [ ] (If applicable) Basic tests pass.
+
+## 💡 Context / Motivation
+
+## 🔗 Related Issues / PRs
+- Fixes #
+- Relates to #
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
new file mode 100644
index 000000000..c96b7da12
--- /dev/null
+++ b/.github/pull_request_template.md
@@ -0,0 +1,43 @@
+## 📝 Description
+
+
+
+## 🗣️ Type of Change
+- [ ] 🐞 Bug fix (non-breaking change which fixes an issue)
+- [ ] ✨ New feature (non-breaking change which adds functionality)
+- [ ] 📖 Documentation update
+- [ ] ⚡ Code refactoring (no functional changes, no api changes)
+
+## 🤖 AI Code Generation
+- [ ] 🤖 Fully AI-generated (100% AI, 0% Human)
+- [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified)
+- [ ] 👨💻 Mostly Human-written (Human lead, AI assisted or none)
+
+
+## 🔗 Related Issue
+
+
+
+## 📚 Technical Context (Skip for Docs)
+- **Reference URL:**
+- **Reasoning:**
+
+## 🧪 Test Environment
+- **Hardware:**
+- **OS:**
+- **Model/Provider:**
+- **Channels:**
+
+
+## 📸 Evidence (Optional)
+
+Click to view Logs/Screenshots
+
+
+
+
+
+## ☑️ Checklist
+- [ ] My code/docs follow the style of this project.
+- [ ] I have performed a self-review of my own changes.
+- [ ] I have updated the documentation accordingly.
\ No newline at end of file
diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml
index 90ff635da..2d1aa9ffc 100644
--- a/.github/workflows/docker-build.yml
+++ b/.github/workflows/docker-build.yml
@@ -1,12 +1,18 @@
name: 🐳 Build & Push Docker Image
on:
- release:
- types: [published]
+ workflow_call:
+ inputs:
+ tag:
+ description: "Release tag"
+ required: true
+ type: string
env:
- REGISTRY: ghcr.io
- IMAGE_NAME: ${{ github.repository_owner }}/picoclaw
+ GHCR_REGISTRY: ghcr.io
+ GHCR_IMAGE_NAME: ${{ github.repository_owner }}/picoclaw
+ DOCKERHUB_REGISTRY: docker.io
+ DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
jobs:
build:
@@ -20,6 +26,8 @@ jobs:
# ── Checkout ──────────────────────────────
- name: 📥 Checkout repository
uses: actions/checkout@v4
+ with:
+ ref: ${{ inputs.tag }}
# ── Docker Buildx ─────────────────────────
- name: 🔧 Set up Docker Buildx
@@ -27,36 +35,42 @@ jobs:
# ── Login to GHCR ─────────────────────────
- name: 🔑 Login to GitHub Container Registry
- if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
- registry: ${{ env.REGISTRY }}
+ registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- # ── Metadata (tags & labels) ──────────────
- - name: 🏷️ Extract Docker metadata
- id: meta
- uses: docker/metadata-action@v5
+ # ── Login to Docker Hub ────────────────────
+ - name: 🔑 Login to Docker Hub
+ uses: docker/login-action@v3
with:
- images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
- tags: |
- type=ref,event=branch
- type=ref,event=pr
- type=semver,pattern={{version}}
- type=semver,pattern={{major}}.{{minor}}
- type=sha,prefix=
- type=raw,value=latest,enable={{is_default_branch}}
- type=raw,value={{date 'YYYYMMDD-HHmmss'}},enable={{is_default_branch}}
+ registry: ${{ env.DOCKERHUB_REGISTRY }}
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # ── Metadata (tags & labels) ──────────────
+ - name: 🏷️ Prepare image tags
+ id: tags
+ shell: bash
+ run: |
+ tag="${{ inputs.tag }}"
+ echo "ghcr_tag=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT"
+ echo "ghcr_latest=${{ env.GHCR_REGISTRY }}/${{ env.GHCR_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
+ echo "dockerhub_tag=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:${tag}" >> "$GITHUB_OUTPUT"
+ echo "dockerhub_latest=${{ env.DOCKERHUB_REGISTRY }}/${{ env.DOCKERHUB_IMAGE_NAME }}:latest" >> "$GITHUB_OUTPUT"
# ── Build & Push ──────────────────────────
- name: 🚀 Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
- push: ${{ github.event_name != 'pull_request' }}
- tags: ${{ steps.meta.outputs.tags }}
- labels: ${{ steps.meta.outputs.labels }}
+ push: true
+ tags: |
+ ${{ steps.tags.outputs.ghcr_tag }}
+ ${{ steps.tags.outputs.ghcr_latest }}
+ ${{ steps.tags.outputs.dockerhub_tag }}
+ ${{ steps.tags.outputs.dockerhub_latest }}
cache-from: type=gha
cache-to: type=gha,mode=max
- platforms: linux/amd64,linux/arm64
+ platforms: linux/amd64,linux/arm64,linux/riscv64
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index e0ea2d281..b6684aacf 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 with race detector
run: go test -race -coverprofile=coverage.txt -covermode=atomic ./...
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 59cc6caeb..4e9399128 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -32,68 +32,71 @@ jobs:
- name: Create and push tag
shell: bash
+ env:
+ RELEASE_TAG: ${{ inputs.tag }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- git tag -a "${{ inputs.tag }}" -m "Release ${{ inputs.tag }}"
- git push origin "${{ inputs.tag }}"
+ git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG"
+ git push origin "$RELEASE_TAG"
- build-binaries:
- name: Build Release Binaries
+ release:
+ name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ packages: write
steps:
- name: Checkout tag
uses: actions/checkout@v4
with:
+ fetch-depth: 0
ref: ${{ inputs.tag }}
- name: Setup Go from go.mod
+ id: setup-go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- - name: Build all binaries
- run: make build-all
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
- - name: Generate checksums
- shell: bash
- run: |
- shasum -a 256 build/picoclaw-* > build/sha256sums.txt
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
- - name: Upload release binaries artifact
- uses: actions/upload-artifact@v4
+ - name: Login to GitHub Container Registry
+ uses: docker/login-action@v3
with:
- name: picoclaw-binaries
- path: |
- build/picoclaw-*
- build/sha256sums.txt
- if-no-files-found: error
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
- create-release:
- name: Create GitHub Release
- needs: [create-tag, build-binaries]
- runs-on: ubuntu-latest
- permissions:
- contents: write
- steps:
- - name: Download all artifacts
- uses: actions/download-artifact@v4
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
with:
- path: release-artifacts
+ registry: docker.io
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
- - name: Show downloaded files
- run: ls -R release-artifacts
-
- - name: Create release
- uses: softprops/action-gh-release@v2
+ - name: Run GoReleaser
+ uses: goreleaser/goreleaser-action@v6
with:
- tag_name: ${{ inputs.tag }}
- name: ${{ inputs.tag }}
- draft: ${{ inputs.draft }}
- prerelease: ${{ inputs.prerelease }}
- files: |
- release-artifacts/**/*
- generate_release_notes: true
+ distribution: goreleaser
+ version: ~> v2
+ args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
+ DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
+ GOVERSION: ${{ steps.setup-go.outputs.go-version }}
+
+ - name: Apply release flags
+ shell: bash
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release edit "${{ inputs.tag }}" \
+ --draft=${{ inputs.draft }} \
+ --prerelease=${{ inputs.prerelease }}
diff --git a/.gitignore b/.gitignore
index 7384e7f5c..0764d4ef9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ build/
*.out
/picoclaw
/picoclaw-test
+cmd/picoclaw/workspace
# Picoclaw specific
@@ -37,6 +38,10 @@ ralph/
.ralph/
tasks/
-# IDE
+# IDE / Editors
.vscode/
-.cursor/
\ No newline at end of file
+.cursor/
+.idea/
+
+# Added by goreleaser init:
+dist/
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
new file mode 100644
index 000000000..2c47f7d86
--- /dev/null
+++ b/.goreleaser.yaml
@@ -0,0 +1,87 @@
+# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
+# vim: set ts=2 sw=2 tw=0 fo=cnqoj
+version: 2
+
+before:
+ hooks:
+ - go mod tidy
+ - go generate ./cmd/picoclaw
+
+builds:
+ - id: picoclaw
+ env:
+ - CGO_ENABLED=0
+ tags:
+ - stdjson
+ ldflags:
+ - -s -w
+ - -X main.version={{ .Version }}
+ - -X main.gitCommit={{ .ShortCommit }}
+ - -X main.buildTime={{ .Date }}
+ - -X main.goVersion={{ .Env.GOVERSION }}
+ goos:
+ - linux
+ - windows
+ - darwin
+ - freebsd
+ goarch:
+ - amd64
+ - arm64
+ - riscv64
+ - s390x
+ - mips64
+ - arm
+ main: ./cmd/picoclaw
+ ignore:
+ - goos: windows
+ goarch: arm
+
+dockers_v2:
+ - id: picoclaw
+ dockerfile: Dockerfile.goreleaser
+ ids:
+ - picoclaw
+ images:
+ - "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
+ - "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}"
+ tags:
+ - "{{ .Tag }}"
+ - "latest"
+ platforms:
+ - linux/amd64
+ - linux/arm64
+ - linux/riscv64
+
+archives:
+ - formats: [tar.gz]
+ # this name template makes the OS and Arch compatible with the results of `uname`.
+ name_template: >-
+ {{ .ProjectName }}_
+ {{- title .Os }}_
+ {{- if eq .Arch "amd64" }}x86_64
+ {{- else if eq .Arch "386" }}i386
+ {{- else }}{{ .Arch }}{{ end }}
+ {{- if .Arm }}v{{ .Arm }}{{ end }}
+ # use zip for windows archives
+ format_overrides:
+ - goos: windows
+ formats: [zip]
+
+changelog:
+ sort: asc
+ filters:
+ exclude:
+ - "^docs:"
+ - "^test:"
+
+# upx:
+# - enabled: true
+# compress: best
+# lzma: true
+
+release:
+ footer: >-
+
+ ---
+
+ Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
diff --git a/Dockerfile b/Dockerfile
index 8db995545..0360cfda6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
-FROM golang:1.25.7-alpine AS builder
+FROM golang:1.26.0-alpine AS builder
RUN apk add --no-cache git make
@@ -18,19 +18,26 @@ 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
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget -q --spider http://localhost:18790/health || exit 1
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
-# Copy builtin skills
-COPY --from=builder /src/skills /opt/picoclaw/skills
+# Create non-root user and group
+RUN addgroup -g 1000 picoclaw && \
+ adduser -D -u 1000 -G picoclaw picoclaw
-# Create picoclaw home directory
-RUN mkdir -p /root/.picoclaw/workspace/skills && \
- cp -r /opt/picoclaw/skills/* /root/.picoclaw/workspace/skills/ 2>/dev/null || true
+# Switch to non-root user
+USER picoclaw
+
+# Run onboard to create initial directories and config
+RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]
diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser
new file mode 100644
index 000000000..0cdc8c6bd
--- /dev/null
+++ b/Dockerfile.goreleaser
@@ -0,0 +1,10 @@
+FROM alpine:3.21
+
+ARG TARGETPLATFORM
+
+RUN apk add --no-cache ca-certificates tzdata
+
+COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
+
+ENTRYPOINT ["picoclaw"]
+CMD ["gateway"]
diff --git a/Makefile b/Makefile
index 2defcce31..ff280e3e4 100644
--- a/Makefile
+++ b/Makefile
@@ -11,11 +11,11 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
-LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)"
+LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w"
# Go variables
GO?=go
-GOFLAGS?=-v
+GOFLAGS?=-v -tags stdjson
# Installation
INSTALL_PREFIX?=$(HOME)/.local
@@ -39,6 +39,8 @@ ifeq ($(UNAME_S),Linux)
ARCH=amd64
else ifeq ($(UNAME_M),aarch64)
ARCH=arm64
+ else ifeq ($(UNAME_M),loongarch64)
+ ARCH=loong64
else ifeq ($(UNAME_M),riscv64)
ARCH=riscv64
else
@@ -63,20 +65,28 @@ 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)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
+ GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
@@ -89,35 +99,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,15 +122,31 @@ clean:
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
+## vet: Run go vet for static analysis
+vet:
+ @$(GO) vet ./...
+
+## fmt: Format Go code
+test:
+ @$(GO) test ./...
+
## fmt: Format Go code
fmt:
@$(GO) fmt ./...
-## deps: Update dependencies
+## deps: Download dependencies
deps:
+ @$(GO) mod download
+ @$(GO) mod verify
+
+## update-deps: Update dependencies
+update-deps:
@$(GO) get -u ./...
@$(GO) mod tidy
+## check: Run vet, fmt, and verify dependencies
+check: deps fmt vet test
+
## run: Build and run picoclaw
run: build
@$(BUILD_DIR)/$(BINARY_NAME) $(ARGS)
diff --git a/README.ja.md b/README.ja.md
new file mode 100644
index 000000000..e7556b5b3
--- /dev/null
+++ b/README.ja.md
@@ -0,0 +1,775 @@
+
+

+
+
PicoClaw: Go で書かれた超効率 AI アシスタント
+
+
$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!
+
+
+
+
+
+
+
+
+[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [English](README.md)
+
+
+
+
+---
+
+🦐 PicoClaw は [nanobot](https://github.com/HKUDS/nanobot) にインスパイアされた超軽量パーソナル AI アシスタントです。Go でゼロからリファクタリングされ、AI エージェント自身がアーキテクチャの移行とコード最適化を推進するセルフブートストラッピングプロセスで構築されました。
+
+⚡️ $10 のハードウェアで 10MB 未満の RAM で動作:OpenClaw より 99% 少ないメモリ、Mac mini より 98% 安い!
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+## 📢 ニュース
+2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ!
+
+## ✨ 特徴
+
+🪶 **超軽量**: メモリフットプリント 10MB 未満 — Clawdbot のコア機能より 99% 小さい。
+
+💰 **最小コスト**: $10 ハードウェアで動作 — Mac mini より 98% 安い。
+
+⚡️ **超高速**: 起動時間 400 倍高速、0.6GHz シングルコアでも 1 秒で起動。
+
+🌍 **真のポータビリティ**: RISC-V、ARM、x86 対応の単一バイナリ。ワンクリックで Go!
+
+🤖 **AI ブートストラップ**: 自律的な Go ネイティブ実装 — コアの 95% が AI 生成、人間によるレビュー付き。
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| --- | --- | --- |--- |
+| **言語** | TypeScript | Python | **Go** |
+| **RAM** | >1GB |>100MB| **< 10MB** |
+| **起動時間**(0.8GHz コア) | >500秒 | >30秒 | **<1秒** |
+| **コスト** | Mac Mini 599$ | 大半の Linux SBC ~50$ |**あらゆる Linux ボード****最安 10$** |
+
+
+
+## 🦾 デモンストレーション
+### 🛠️ スタンダードアシスタントワークフロー
+
+
+ 🧩 フルスタックエンジニア |
+ 🗂️ ログ&計画管理 |
+ 🔎 Web 検索&学習 |
+
+
+ 
|
+ 
|
+ 
|
+
+
+ | 開発 · デプロイ · スケール |
+ スケジュール · 自動化 · メモリ |
+ 発見 · インサイト · トレンド |
+
+
+
+### 🐜 革新的な省フットプリントデプロイ
+PicoClaw はほぼすべての Linux デバイスにデプロイできます!
+
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) または W(WiFi6) バージョン、最小ホームアシスタントに
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) または $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) サーバー自動メンテナンスに
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) または $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) スマート監視に
+
+https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
+
+🌟 もっと多くのデプロイ事例が待っています!
+
+## 📦 インストール
+
+### コンパイル済みバイナリでインストール
+
+[リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のファームウェアをダウンロードしてください。
+
+### ソースからインストール(最新機能、開発向け推奨)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# ビルド(インストール不要)
+make build
+
+# 複数プラットフォーム向けビルド
+make build-all
+
+# ビルドとインストール
+make install
+```
+
+## 🐳 Docker Compose
+
+Docker Compose を使えば、ローカルにインストールせずに PicoClaw を実行できます。
+
+```bash
+# 1. リポジトリをクローン
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. API キーを設定
+cp config/config.example.json config/config.json
+vim config/config.json # DISCORD_BOT_TOKEN, プロバイダーの API キーを設定
+
+# 3. ビルドと起動
+docker compose --profile gateway up -d
+
+# 4. ログ確認
+docker compose logs -f picoclaw-gateway
+
+# 5. 停止
+docker compose --profile gateway down
+```
+
+### Agent モード(ワンショット)
+
+```bash
+# 質問を投げる
+docker compose run --rm picoclaw-agent -m "What is 2+2?"
+
+# インタラクティブモード
+docker compose run --rm picoclaw-agent
+```
+
+### リビルド
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --profile gateway up -d
+```
+
+### 🚀 クイックスタート(ネイティブ)
+
+> [!TIP]
+> `~/.picoclaw/config.json` に API キーを設定してください。
+> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
+> Web 検索は **任意** です - 無料の [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
+
+**1. 初期化**
+
+```bash
+picoclaw onboard
+```
+
+**2. 設定** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "xxx",
+ "api_base": "https://openrouter.ai/api/v1"
+ }
+ },
+ "tools": {
+ "web": {
+ "search": {
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+**3. API キーの取得**
+
+- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
+
+> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
+
+**3. チャット**
+
+```bash
+picoclaw agent -m "What is 2+2?"
+```
+
+これだけです!2 分で AI アシスタントが動きます。
+
+---
+
+## 💬 チャットアプリ
+
+Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
+
+| チャネル | セットアップ |
+|---------|------------|
+| **Telegram** | 簡単(トークンのみ) |
+| **Discord** | 簡単(Bot トークン + Intents) |
+| **QQ** | 簡単(AppID + AppSecret) |
+| **DingTalk** | 普通(アプリ認証情報) |
+| **LINE** | 普通(認証情報 + Webhook URL) |
+
+
+Telegram(推奨)
+
+**1. Bot を作成**
+
+- Telegram を開き、`@BotFather` を検索
+- `/newbot` を送信、プロンプトに従う
+- トークンをコピー
+
+**2. 設定**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> ユーザー ID は Telegram の `@userinfobot` から取得できます。
+
+**3. 起動**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+Discord
+
+**1. Bot を作成**
+- https://discord.com/developers/applications にアクセス
+- アプリケーションを作成 → Bot → Add Bot
+- Bot トークンをコピー
+
+**2. Intents を有効化**
+- Bot の設定画面で **MESSAGE CONTENT INTENT** を有効化
+- (任意)**SERVER MEMBERS INTENT** も有効化
+
+**3. ユーザー ID を取得**
+- Discord 設定 → 詳細設定 → **開発者モード** を有効化
+- 自分のアバターを右クリック → **ユーザーIDをコピー**
+
+**4. 設定**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Bot を招待**
+- OAuth2 → URL Generator
+- Scopes: `bot`
+- Bot Permissions: `Send Messages`, `Read Message History`
+- 生成された招待 URL を開き、サーバーに Bot を追加
+
+**6. 起動**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+QQ
+
+**1. Bot を作成**
+
+- [QQ オープンプラットフォーム](https://q.qq.com/#) にアクセス
+- アプリケーションを作成 → **AppID** と **AppSecret** を取得
+
+**2. 設定**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> `allow_from` を空にすると全ユーザーを許可、QQ番号を指定してアクセス制限可能。
+
+**3. 起動**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Bot を作成**
+
+- [オープンプラットフォーム](https://open.dingtalk.com/) にアクセス
+- 内部アプリを作成
+- Client ID と Client Secret をコピー
+
+**2. 設定**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> `allow_from` を空にすると全ユーザーを許可、ユーザーIDを指定してアクセス制限可能。
+
+**3. 起動**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+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`
+
+### ワークスペース構成
+
+PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw/workspace`)にデータを保存します:
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # 会話セッションと履歴
+├── memory/ # 長期メモリ(MEMORY.md)
+├── state/ # 永続状態(最後のチャネルなど)
+├── cron/ # スケジュールジョブデータベース
+├── skills/ # カスタムスキル
+├── AGENTS.md # エージェントの行動ガイド
+├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認)
+├── IDENTITY.md # エージェントのアイデンティティ
+├── SOUL.md # エージェントのソウル
+├── TOOLS.md # ツールの説明
+└── USER.md # ユーザー設定
+```
+
+### 🔒 セキュリティサンドボックス
+
+PicoClaw はデフォルトでサンドボックス環境で実行されます。エージェントは設定されたワークスペース内のファイルにのみアクセスし、コマンドを実行できます。
+
+#### デフォルト設定
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "restrict_to_workspace": true
+ }
+ }
+}
+```
+
+| オプション | デフォルト | 説明 |
+|-----------|-----------|------|
+| `workspace` | `~/.picoclaw/workspace` | エージェントの作業ディレクトリ |
+| `restrict_to_workspace` | `true` | ファイル/コマンドアクセスをワークスペースに制限 |
+
+#### 保護対象ツール
+
+`restrict_to_workspace: true` の場合、以下のツールがサンドボックス化されます:
+
+| ツール | 機能 | 制限 |
+|-------|------|------|
+| `read_file` | ファイル読み込み | ワークスペース内のファイルのみ |
+| `write_file` | ファイル書き込み | ワークスペース内のファイルのみ |
+| `list_dir` | ディレクトリ一覧 | ワークスペース内のディレクトリのみ |
+| `edit_file` | ファイル編集 | ワークスペース内のファイルのみ |
+| `append_file` | ファイル追記 | ワークスペース内のファイルのみ |
+| `exec` | コマンド実行 | コマンドパスはワークスペース内である必要あり |
+
+#### exec ツールの追加保護
+
+`restrict_to_workspace: false` でも、`exec` ツールは以下の危険なコマンドをブロックします:
+
+- `rm -rf`, `del /f`, `rmdir /s` — 一括削除
+- `format`, `mkfs`, `diskpart` — ディスクフォーマット
+- `dd if=` — ディスクイメージング
+- `/dev/sd[a-z]` への書き込み — 直接ディスク書き込み
+- `shutdown`, `reboot`, `poweroff` — システムシャットダウン
+- フォークボム `:(){ :|:& };:`
+
+#### エラー例
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (path outside working dir)}
+```
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
+```
+
+#### 制限の無効化(セキュリティリスク)
+
+エージェントにワークスペース外のパスへのアクセスが必要な場合:
+
+**方法1: 設定ファイル**
+```json
+{
+ "agents": {
+ "defaults": {
+ "restrict_to_workspace": false
+ }
+ }
+}
+```
+
+**方法2: 環境変数**
+```bash
+export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
+```
+
+> ⚠️ **警告**: この制限を無効にすると、エージェントはシステム上の任意のパスにアクセスできるようになります。制御された環境でのみ慎重に使用してください。
+
+#### セキュリティ境界の一貫性
+
+`restrict_to_workspace` 設定は、すべての実行パスで一貫して適用されます:
+
+| 実行パス | セキュリティ境界 |
+|---------|-----------------|
+| メインエージェント | `restrict_to_workspace` ✅ |
+| サブエージェント / Spawn | 同じ制限を継承 ✅ |
+| ハートビートタスク | 同じ制限を継承 ✅ |
+
+すべてのパスで同じワークスペース制限が適用されます — サブエージェントやスケジュールタスクを通じてセキュリティ境界をバイパスする方法はありません。
+
+### ハートビート(定期タスク)
+
+PicoClaw は自動的に定期タスクを実行できます。ワークスペースに `HEARTBEAT.md` ファイルを作成します:
+
+```markdown
+# 定期タスク
+
+- 重要なメールをチェック
+- 今後の予定を確認
+- 天気予報をチェック
+```
+
+エージェントは30分ごと(設定可能)にこのファイルを読み込み、利用可能なツールを使ってタスクを実行します。
+
+#### spawn で非同期タスク実行
+
+時間のかかるタスク(Web検索、API呼び出し)には `spawn` ツールを使って**サブエージェント**を作成します:
+
+```markdown
+# 定期タスク
+
+## クイックタスク(直接応答)
+- 現在時刻を報告
+
+## 長時間タスク(spawn で非同期)
+- AIニュースを検索して要約
+- メールをチェックして重要なメッセージを報告
+```
+
+**主な特徴:**
+
+| 機能 | 説明 |
+|------|------|
+| **spawn** | 非同期サブエージェントを作成、ハートビートをブロックしない |
+| **独立コンテキスト** | サブエージェントは独自のコンテキストを持ち、セッション履歴なし |
+| **message ツール** | サブエージェントは message ツールで直接ユーザーと通信 |
+| **非ブロッキング** | spawn 後、ハートビートは次のタスクへ継続 |
+
+#### サブエージェントの通信方法
+
+```
+ハートビート発動
+ ↓
+エージェントが HEARTBEAT.md を読む
+ ↓
+長いタスク: spawn サブエージェント
+ ↓ ↓
+次のタスクへ継続 サブエージェントが独立して動作
+ ↓ ↓
+全タスク完了 message ツールを使用
+ ↓ ↓
+HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
+```
+
+サブエージェントはツール(message、web_search など)にアクセスでき、メインエージェントを経由せずにユーザーと通信できます。
+
+**設定:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| オプション | デフォルト | 説明 |
+|-----------|-----------|------|
+| `enabled` | `true` | ハートビートの有効/無効 |
+| `interval` | `30` | チェック間隔(分)、最小5分 |
+
+**環境変数:**
+- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
+- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
+
+### 基本設定
+
+1. **設定ファイルの作成:**
+
+ ```bash
+ cp config.example.json config/config.json
+ ```
+
+2. **設定の編集:**
+
+ ```json
+ {
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-..."
+ }
+ },
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_DISCORD_BOT_TOKEN"
+ }
+ }
+ }
+ ```
+
+3. **実行**
+
+ ```bash
+ picoclaw agent -m "Hello"
+ ```
+
+
+
+完全な設定例
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "apiKey": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "apiKey": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allowFrom": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "appId": "cli_xxx",
+ "appSecret": "xxx",
+ "encryptKey": "",
+ "verificationToken": "",
+ "allowFrom": []
+ }
+ },
+ "tools": {
+ "web": {
+ "search": {
+ "apiKey": "BSA..."
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+## CLI リファレンス
+
+| コマンド | 説明 |
+|---------|------|
+| `picoclaw onboard` | 設定&ワークスペースの初期化 |
+| `picoclaw agent -m "..."` | エージェントとチャット |
+| `picoclaw agent` | インタラクティブチャットモード |
+| `picoclaw gateway` | ゲートウェイを起動 |
+| `picoclaw status` | ステータスを表示 |
+
+## 🤝 コントリビュート&ロードマップ
+
+PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗
+
+Discord: https://discord.gg/V4sAZ9XWpN
+
+
+
+
+## 🐛 トラブルシューティング
+
+### Web 検索で「API 設定の問題」と表示される
+
+検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。
+
+Web 検索を有効にするには:
+1. [https://brave.com/search/api](https://brave.com/search/api) で無料の API キーを取得(月 2000 クエリ無料)
+2. `~/.picoclaw/config.json` に追加:
+ ```json
+ {
+ "tools": {
+ "web": {
+ "search": {
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ }
+ }
+ }
+ }
+ ```
+
+### コンテンツフィルタリングエラーが出る
+
+一部のプロバイダー(Zhipu など)にはコンテンツフィルタリングがあります。クエリを言い換えるか、別のモデルを使用してください。
+
+### Telegram Bot で「Conflict: terminated by other getUpdates」と表示される
+
+別のインスタンスが実行中の場合に発生します。`picoclaw gateway` が 1 つだけ実行されていることを確認してください。
+
+---
+
+## 📝 API キー比較
+
+| サービス | 無料枠 | ユースケース |
+|---------|--------|------------|
+| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
+| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
+| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
+| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
diff --git a/README.pt-br.md b/README.pt-br.md
new file mode 100644
index 000000000..d250cc956
--- /dev/null
+++ b/README.pt-br.md
@@ -0,0 +1,881 @@
+
+

+
+
PicoClaw: Assistente de IA Ultra-Eficiente em Go
+
+
Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!
+
+
+
+
+
+
+
+
+
+
+ [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Português**
+
+
+---
+
+🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [nanobot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código.
+
+⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!
+
+
+
+|
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+> [!CAUTION]
+> **🚨 DECLARACAO DE SEGURANCA & CANAIS OFICIAIS**
+>
+> * **SEM CRIPTOMOEDAS:** O PicoClaw **NAO** possui nenhum token/moeda oficial. Todas as alegacoes no `pump.fun` ou outras plataformas de negociacao sao **GOLPES**.
+> * **DOMINIO OFICIAL:** O **UNICO** site oficial e **[picoclaw.io](https://picoclaw.io)**, e o site da empresa e **[sipeed.com](https://sipeed.com)**.
+> * **Aviso:** Muitos dominios `.ai/.org/.com/.net/...` foram registrados por terceiros, nao sao nossos.
+> * **Aviso:** O PicoClaw esta em fase inicial de desenvolvimento e pode ter problemas de seguranca de rede nao resolvidos. Nao implante em ambientes de producao antes da versao v1.0.
+> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memoria (10-20MB) nas versoes mais recentes. Planejamos priorizar a otimizacao de recursos assim que o conjunto de funcionalidades estiver estavel.
+
+
+## 📢 Novidades
+
+2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw esta crescendo mais rapido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papeis de voluntarios e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter voce a bordo!
+
+2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado a comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
+🚀 **Chamada para Acao:** Envie suas solicitacoes de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na proxima reuniao semanal.
+
+2026-02-09 🎉 PicoClaw lancado oficialmente! Construido em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu!
+
+## ✨ Funcionalidades
+
+🪶 **Ultra-Leve**: Consumo de memoria <10MB — 99% menor que o Clawdbot para funcionalidades essenciais.
+
+💰 **Custo Minimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
+
+⚡️ **Inicializacao Relampago**: Tempo de inicializacao 400X mais rapido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
+
+🌍 **Portabilidade Real**: Um unico binario auto-contido para RISC-V, ARM e x86. Um clique e ja era!
+
+🤖 **Auto-Construido por IA**: Implementacao nativa em Go de forma autonoma — 95% do nucleo gerado pelo Agente com refinamento humano no loop.
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Linguagem** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **Inicializacao**(CPU 0.8GHz) | >500s | >30s | **<1s** |
+| **Custo** | Mac Mini $599 | Maioria dos SBC Linux ~$50 | **Qualquer placa Linux****A partir de $10** |
+
+
+
+## 🦾 Demonstracao
+
+### 🛠️ Fluxos de Trabalho Padrao do Assistente
+
+
+
+🧩 Engenharia Full-Stack |
+🗂️ Gerenciamento de Logs & Planejamento |
+🔎 Busca Web & Aprendizado |
+
+
+
|
+
|
+
|
+
+
+| Desenvolver • Implantar • Escalar |
+Agendar • Automatizar • Memorizar |
+Descobrir • Analisar • Tendencias |
+
+
+
+### 📱 Rode em celulares Android antigos
+
+De uma segunda vida ao seu celular de dez anos atras! Transforme-o em um assistente de IA inteligente com o PicoClaw. Inicio rapido:
+
+1. **Instale o Termux** (Disponivel no F-Droid ou Google Play).
+2. **Execute os comandos**
+
+```bash
+# Nota: Substitua v0.1.1 pela versao mais recente da pagina de Releases
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+
+Depois siga as instrucoes na secao "Inicio Rapido" para completar a configuracao!
+
+
+
+### 🐜 Implantacao Inovadora com Baixo Consumo
+
+O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
+
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versao E (Ethernet) ou W (WiFi6), para Assistente Domestico Minimalista
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutencao Automatizada de Servidores
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente
+
+https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
+
+🌟 Mais cenarios de implantacao aguardam voce!
+
+## 📦 Instalacao
+
+### Instalar com binario pre-compilado
+
+Baixe o binario para sua plataforma na pagina de [releases](https://github.com/sipeed/picoclaw/releases).
+
+### Instalar a partir do codigo-fonte (funcionalidades mais recentes, recomendado para desenvolvimento)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Build, sem necessidade de instalar
+make build
+
+# Build para multiplas plataformas
+make build-all
+
+# Build e Instalar
+make install
+```
+
+## 🐳 Docker Compose
+
+Voce tambem pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente.
+
+```bash
+# 1. Clone este repositorio
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Configure suas API keys
+cp config/config.example.json config/config.json
+vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
+
+# 3. Build & Iniciar
+docker compose --profile gateway up -d
+
+# 4. Ver logs
+docker compose logs -f picoclaw-gateway
+
+# 5. Parar
+docker compose --profile gateway down
+```
+
+### Modo Agente (Execucao unica)
+
+```bash
+# Fazer uma pergunta
+docker compose run --rm picoclaw-agent -m "Quanto e 2+2?"
+
+# Modo interativo
+docker compose run --rm picoclaw-agent
+```
+
+### Rebuild
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --profile gateway up -d
+```
+
+### 🚀 Inicio Rapido
+
+> [!TIP]
+> Configure sua API key em `~/.picoclaw/config.json`.
+> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
+> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas gratis/mes) ou use o fallback automatico integrado.
+
+**1. Inicializar**
+
+```bash
+picoclaw onboard
+```
+
+**2. Configurar** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "xxx",
+ "api_base": "https://openrouter.ai/api/v1"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+**3. Obter API Keys**
+
+* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponivel (2000 consultas/mes)
+
+> **Nota**: Veja `config.example.json` para um modelo de configuracao completo.
+
+**4. Conversar**
+
+```bash
+picoclaw agent -m "Quanto e 2+2?"
+```
+
+Pronto! Voce tem um assistente de IA funcionando em 2 minutos.
+
+---
+
+## 💬 Integracao com Apps de Chat
+
+Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
+
+| Canal | Nivel de Configuracao |
+| --- | --- |
+| **Telegram** | Facil (apenas um token) |
+| **Discord** | Facil (bot token + intents) |
+| **QQ** | Facil (AppID + AppSecret) |
+| **DingTalk** | Medio (credenciais do app) |
+| **LINE** | Medio (credenciais + webhook URL) |
+
+
+Telegram (Recomendado)
+
+**1. Criar o bot**
+
+* Abra o Telegram, busque `@BotFather`
+* Envie `/newbot`, siga as instrucoes
+* Copie o token
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> Obtenha seu User ID pelo `@userinfobot` no Telegram.
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+Discord
+
+**1. Criar o bot**
+
+* Acesse
+* Crie um aplicativo → Bot → Add Bot
+* Copie o token do bot
+
+**2. Habilitar Intents**
+
+* Nas configuracoes do Bot, habilite **MESSAGE CONTENT INTENT**
+* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissoes baseada em dados dos membros
+
+**3. Obter seu User ID**
+
+* Configuracoes do Discord → Avancado → habilite **Modo Desenvolvedor**
+* Clique com botao direito no seu avatar → **Copiar ID do Usuario**
+
+**4. Configurar**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Convidar o bot**
+
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* Abra a URL de convite gerada e adicione o bot ao seu servidor
+
+**6. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+QQ
+
+**1. Criar o bot**
+
+- Acesse a [QQ Open Platform](https://q.qq.com/#)
+- Crie um aplicativo → Obtenha **AppID** e **AppSecret**
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique numeros QQ para restringir o acesso.
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Criar o bot**
+
+* Acesse a [Open Platform](https://open.dingtalk.com/)
+* Crie um app interno
+* Copie o Client ID e Client Secret
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique IDs para restringir o acesso.
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+LINE
+
+**1. Criar uma Conta Oficial LINE**
+
+- Acesse o [LINE Developers Console](https://developers.line.biz/)
+- Crie um provider → Crie um canal Messaging API
+- Copie o **Channel Secret** e o **Channel Access Token**
+
+**2. Configurar**
+
+```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. Configurar URL do Webhook**
+
+O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel:
+
+```bash
+# Exemplo com ngrok
+ngrok http 18791
+```
+
+Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
+
+**4. Executar**
+
+```bash
+picoclaw gateway
+```
+
+> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
+
+> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao servico `picoclaw-gateway` para expor a porta do webhook.
+
+
+
+##
Junte-se a Rede Social de Agentes
+
+Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma unica mensagem via CLI ou qualquer App de Chat integrado.
+
+**Leia `https://clawdchat.ai/skill.md` e siga as instrucoes para entrar no [ClawdChat.ai](https://clawdchat.ai)**
+
+## ⚙️ Configuracao Detalhada
+
+Arquivo de configuracao: `~/.picoclaw/config.json`
+
+### Estrutura do Workspace
+
+O PicoClaw armazena dados no workspace configurado (padrao: `~/.picoclaw/workspace`):
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # Sessoes de conversa e historico
+├── memory/ # Memoria de longo prazo (MEMORY.md)
+├── state/ # Estado persistente (ultimo canal, etc.)
+├── cron/ # Banco de dados de tarefas agendadas
+├── skills/ # Skills personalizadas
+├── AGENTS.md # Guia de comportamento do Agente
+├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
+├── IDENTITY.md # Identidade do Agente
+├── SOUL.md # Alma do Agente
+├── TOOLS.md # Descricao das ferramentas
+└── USER.md # Preferencias do usuario
+```
+
+### 🔒 Sandbox de Seguranca
+
+O PicoClaw roda em um ambiente sandbox por padrao. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado.
+
+#### Configuracao Padrao
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "restrict_to_workspace": true
+ }
+ }
+}
+```
+
+| Opcao | Padrao | Descricao |
+|-------|--------|-----------|
+| `workspace` | `~/.picoclaw/workspace` | Diretorio de trabalho do agente |
+| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace |
+
+#### Ferramentas Protegidas
+
+Quando `restrict_to_workspace: true`, as seguintes ferramentas sao restritas ao sandbox:
+
+| Ferramenta | Funcao | Restricao |
+|------------|--------|-----------|
+| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace |
+| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace |
+| `list_dir` | Listar diretorios | Apenas diretorios dentro do workspace |
+| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace |
+| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace |
+| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace |
+
+#### Protecao Adicional do Exec
+
+Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos:
+
+* `rm -rf`, `del /f`, `rmdir /s` — Exclusao em massa
+* `format`, `mkfs`, `diskpart` — Formatacao de disco
+* `dd if=` — Criacao de imagem de disco
+* Escrita em `/dev/sd[a-z]` — Escrita direta no disco
+* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema
+* Fork bomb `:(){ :|:& };:`
+
+#### Exemplos de Erro
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (path outside working dir)}
+```
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
+```
+
+#### Desabilitar Restricoes (Risco de Seguranca)
+
+Se voce precisa que o agente acesse caminhos fora do workspace:
+
+**Metodo 1: Arquivo de configuracao**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "restrict_to_workspace": false
+ }
+ }
+}
+```
+
+**Metodo 2: Variavel de ambiente**
+
+```bash
+export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
+```
+
+> ⚠️ **Aviso**: Desabilitar esta restricao permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados.
+
+#### Consistencia do Limite de Seguranca
+
+A configuracao `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execucao:
+
+| Caminho de Execucao | Limite de Seguranca |
+|----------------------|---------------------|
+| Agente Principal | `restrict_to_workspace` ✅ |
+| Subagente / Spawn | Herda a mesma restricao ✅ |
+| Tarefas Heartbeat | Herda a mesma restricao ✅ |
+
+Todos os caminhos compartilham a mesma restricao de workspace — nao ha como contornar o limite de seguranca por meio de subagentes ou tarefas agendadas.
+
+### Heartbeat (Tarefas Periodicas)
+
+O PicoClaw pode executar tarefas periodicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace:
+
+```markdown
+# Tarefas Periodicas
+
+- Verificar meu email para mensagens importantes
+- Revisar minha agenda para proximos eventos
+- Verificar a previsao do tempo
+```
+
+O agente lera este arquivo a cada 30 minutos (configuravel) e executara as tarefas usando as ferramentas disponiveis.
+
+#### Tarefas Assincronas com Spawn
+
+Para tarefas de longa duracao (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**:
+
+```markdown
+# Tarefas Periodicas
+
+## Tarefas Rapidas (resposta direta)
+- Informar hora atual
+
+## Tarefas Longas (usar spawn para async)
+- Buscar noticias de IA na web e resumir
+- Verificar email e reportar mensagens importantes
+```
+
+**Comportamentos principais:**
+
+| Funcionalidade | Descricao |
+|----------------|-----------|
+| **spawn** | Cria subagente assincrono, nao bloqueia o heartbeat |
+| **Contexto independente** | Subagente tem seu proprio contexto, sem historico de sessao |
+| **Ferramenta message** | Subagente se comunica diretamente com o usuario via ferramenta message |
+| **Nao-bloqueante** | Apos o spawn, o heartbeat continua para a proxima tarefa |
+
+#### Como Funciona a Comunicacao do Subagente
+
+```
+Heartbeat dispara
+ ↓
+Agente le HEARTBEAT.md
+ ↓
+Para tarefa longa: spawn subagente
+ ↓ ↓
+Continua proxima tarefa Subagente trabalha independentemente
+ ↓ ↓
+Todas tarefas concluidas Subagente usa ferramenta "message"
+ ↓ ↓
+Responde HEARTBEAT_OK Usuario recebe resultado diretamente
+```
+
+O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comunicar com o usuario independentemente sem passar pelo agente principal.
+
+**Configuracao:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Opcao | Padrao | Descricao |
+|-------|--------|-----------|
+| `enabled` | `true` | Habilitar/desabilitar heartbeat |
+| `interval` | `30` | Intervalo de verificacao em minutos (min: 5) |
+
+**Variaveis de ambiente:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo
+
+### Provedores
+
+> [!NOTE]
+> O Groq fornece transcricao de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serao automaticamente transcritas.
+
+| Provedor | Finalidade | Obter API Key |
+| --- | --- | --- |
+| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **Transcricao de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
+
+
+Configuracao Zhipu
+
+**1. Obter API key**
+
+* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+
+**2. Configurar**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Sua API Key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+**3. Executar**
+
+```bash
+picoclaw agent -m "Ola, como vai?"
+```
+
+
+
+
+Exemplo de configuracao completa
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "",
+ "app_secret": "",
+ "allow_from": []
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "BSA...",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+## Referencia CLI
+
+| Comando | Descricao |
+| --- | --- |
+| `picoclaw onboard` | Inicializar configuracao & workspace |
+| `picoclaw agent -m "..."` | Conversar com o agente |
+| `picoclaw agent` | Modo de chat interativo |
+| `picoclaw gateway` | Iniciar o gateway (para bots de chat) |
+| `picoclaw status` | Mostrar status |
+| `picoclaw cron list` | Listar todas as tarefas agendadas |
+| `picoclaw cron add ...` | Adicionar uma tarefa agendada |
+
+### Tarefas Agendadas / Lembretes
+
+O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:
+
+* **Lembretes unicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez apos 10min
+* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas
+* **Expressoes Cron**: "Remind me at 9am daily" (Me lembre as 9h todos os dias) → usa expressao cron
+
+As tarefas sao armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente.
+
+## 🤝 Contribuir & Roadmap
+
+PRs sao bem-vindos! O codigo-fonte e intencionalmente pequeno e legivel. 🤗
+
+Roadmap em breve...
+
+Grupo de desenvolvedores em formacao. Requisito de entrada: Pelo menos 1 PR com merge.
+
+Grupos de usuarios:
+
+Discord:
+
+
+
+## 🐛 Solucao de Problemas
+
+### Busca web mostra "API 配置问题"
+
+Isso e normal se voce ainda nao configurou uma API key de busca. O PicoClaw fornecera links uteis para busca manual.
+
+Para habilitar a busca web:
+
+1. **Opcao 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas gratis/mes) para os melhores resultados.
+2. **Opcao 2 (Sem Cartao de Credito)**: Se voce nao tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key).
+
+Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": true,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+### Erros de filtragem de conteudo
+
+Alguns provedores (como Zhipu) possuem filtragem de conteudo. Tente reformular sua pergunta ou use um modelo diferente.
+
+### Bot do Telegram diz "Conflict: terminated by other getUpdates"
+
+Isso acontece quando outra instancia do bot esta rodando. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez.
+
+---
+
+## 📝 Comparacao de API Keys
+
+| Servico | Plano Gratuito | Caso de Uso |
+| --- | --- | --- |
+| **OpenRouter** | 200K tokens/mes | Multiplos modelos (Claude, GPT-4, etc.) |
+| **Zhipu** | 200K tokens/mes | Melhor para usuarios chineses |
+| **Brave Search** | 2000 consultas/mes | Funcionalidade de busca web |
+| **Groq** | Plano gratuito disponivel | Inferencia ultra-rapida (Llama, Mixtral) |
diff --git a/README.zh.md b/README.zh.md
new file mode 100644
index 000000000..6c87ba785
--- /dev/null
+++ b/README.zh.md
@@ -0,0 +1,744 @@
+
+

+
+
PicoClaw: 基于Go语言的超高效 AI 助手
+
+
10$硬件 · 10MB内存 · 1秒启动 · 皮皮虾,我们走!
+
+
+
+
+
+
+
+
+
+
+ **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [English](README.md)
+
+
+---
+
+🦐 **PicoClaw** 是一个受 [nanobot](https://github.com/HKUDS/nanobot) 启发的超轻量级个人 AI 助手。它采用 **Go 语言** 从零重构,经历了一个“自举”过程——即由 AI Agent 自身驱动了整个架构迁移和代码优化。
+
+⚡️ **极致轻量**:可在 **10 美元** 的硬件上运行,内存占用 **<10MB**。这意味着比 OpenClaw 节省 99% 的内存,比 Mac mini 便宜 98%!
+
+
+
+|
+
+
+
+ |
+
+
+
+
+ |
+
+
+
+注意:人手有限,中文文档可能略有滞后,请优先查看英文文档。
+
+> [!CAUTION]
+> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
+> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
+> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
+> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
+> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
+> * **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
+
+
+## 📢 新闻 (News)
+2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与!
+
+2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
+🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
+
+2026-02-09 🎉 **PicoClaw 正式发布!** 仅用 1 天构建,旨在将 AI Agent 带入 10 美元硬件与 <10MB 内存的世界。🦐 PicoClaw(皮皮虾),我们走!
+
+## ✨ 特性
+
+🪶 **超轻量级**: 核心功能内存占用 <10MB — 比 Clawdbot 小 99%。
+
+💰 **极低成本**: 高效到足以在 10 美元的硬件上运行 — 比 Mac mini 便宜 98%。
+
+⚡️ **闪电启动**: 启动速度快 400 倍,即使在 0.6GHz 单核处理器上也能在 1 秒内启动。
+
+🌍 **真正可移植**: 跨 RISC-V、ARM 和 x86 架构的单二进制文件,一键运行!
+
+🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| --- | --- | --- | --- |
+| **语言** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **启动时间**(0.8GHz core) | >500s | >30s | **<1s** |
+| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板****低至 $10** |
+
+
+
+## 🦾 演示
+
+### 🛠️ 标准助手工作流
+
+
+
+🧩 全栈工程师模式 |
+🗂️ 日志与规划管理 |
+🔎 网络搜索与学习 |
+
+
+
|
+
|
+
|
+
+
+| 开发 • 部署 • 扩展 |
+日程 • 自动化 • 记忆 |
+发现 • 洞察 • 趋势 |
+
+
+
+### 📱 在手机上轻松运行
+picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
+1. 先去应用商店下载安装Termux
+2. 打开后执行指令
+```bash
+# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
+
+
+
+
+
+### 🐜 创新的低占用部署
+
+PicoClaw 几乎可以部署在任何 Linux 设备上!
+
+* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
+* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
+* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
+
+[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
+
+🌟 更多部署案例敬请期待!
+
+## 📦 安装
+
+### 使用预编译二进制文件安装
+
+从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的固件。
+
+### 从源码安装(获取最新特性,开发推荐)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# 构建(无需安装)
+make build
+
+# 为多平台构建
+make build-all
+
+# 构建并安装
+make install
+
+```
+
+## 🐳 Docker Compose
+
+您也可以使用 Docker Compose 运行 PicoClaw,无需在本地安装任何环境。
+
+```bash
+# 1. 克隆仓库
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. 设置 API Key
+cp config/config.example.json config/config.json
+vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
+
+# 3. 构建并启动
+docker compose --profile gateway up -d
+
+# 4. 查看日志
+docker compose logs -f picoclaw-gateway
+
+# 5. 停止
+docker compose --profile gateway down
+
+```
+
+### Agent 模式 (一次性运行)
+
+```bash
+# 提问
+docker compose run --rm picoclaw-agent -m "2+2 等于几?"
+
+# 交互模式
+docker compose run --rm picoclaw-agent
+
+```
+
+### 重新构建
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --profile gateway up -d
+
+```
+
+### 🚀 快速开始
+
+> [!TIP]
+> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
+> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
+> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
+
+**1. 初始化 (Initialize)**
+
+```bash
+picoclaw onboard
+
+```
+
+**2. 配置 (Configure)** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "xxx",
+ "api_base": "https://openrouter.ai/api/v1"
+ }
+ },
+ "tools": {
+ "web": {
+ "search": {
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ }
+}
+
+```
+
+**3. 获取 API Key**
+
+* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
+
+> **注意**: 完整的配置模板请参考 `config.example.json`。
+
+**4. 对话 (Chat)**
+
+```bash
+picoclaw agent -m "2+2 等于几?"
+
+```
+
+就是这样!您在 2 分钟内就拥有了一个可工作的 AI 助手。
+
+---
+
+## 💬 聊天应用集成 (Chat Apps)
+
+通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
+
+| 渠道 | 设置难度 |
+| --- | --- |
+| **Telegram** | 简单 (仅需 token) |
+| **Discord** | 简单 (bot token + intents) |
+| **QQ** | 简单 (AppID + AppSecret) |
+| **钉钉 (DingTalk)** | 中等 (app credentials) |
+
+
+Telegram (推荐)
+
+**1. 创建机器人**
+
+* 打开 Telegram,搜索 `@BotFather`
+* 发送 `/newbot`,按照提示操作
+* 复制 token
+
+**2. 配置**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+
+```
+
+> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
+
+**3. 运行**
+
+```bash
+picoclaw gateway
+
+```
+
+
+
+
+Discord
+
+**1. 创建机器人**
+
+* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
+* Create an application → Bot → Add Bot
+* 复制 bot token
+
+**2. 开启 Intents**
+
+* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
+* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
+
+**3. 获取您的 User ID**
+
+* Discord 设置 → Advanced → 开启 **Developer Mode**
+* 右键点击您的头像 → **Copy User ID**
+
+**4. 配置**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allowFrom": ["YOUR_USER_ID"]
+ }
+ }
+}
+
+```
+
+**5. 邀请机器人**
+
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* 打开生成的邀请 URL,将机器人添加到您的服务器
+
+**6. 运行**
+
+```bash
+picoclaw gateway
+
+```
+
+
+
+
+QQ
+
+**1. 创建机器人**
+
+* 前往 [QQ 开放平台](https://q.qq.com/#)
+* 创建应用 → 获取 **AppID** 和 **AppSecret**
+
+**2. 配置**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+
+```
+
+> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
+
+**3. 运行**
+
+```bash
+picoclaw gateway
+
+```
+
+
+
+
+钉钉 (DingTalk)
+
+**1. 创建机器人**
+
+* 前往 [开放平台](https://open.dingtalk.com/)
+* 创建内部应用
+* 复制 Client ID 和 Client Secret
+
+**2. 配置**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+
+```
+
+> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
+
+**3. 运行**
+
+```bash
+picoclaw gateway
+
+```
+
+
+
+##
加入 Agent 社交网络
+
+只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
+
+**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai)
+
+## ⚙️ 配置详解
+
+配置文件路径: `~/.picoclaw/config.json`
+
+### 工作区布局 (Workspace Layout)
+
+PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/workspace`):
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # 对话会话和历史
+├── memory/ # 长期记忆 (MEMORY.md)
+├── state/ # 持久化状态 (最后一次频道等)
+├── cron/ # 定时任务数据库
+├── skills/ # 自定义技能
+├── AGENTS.md # Agent 行为指南
+├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
+├── IDENTITY.md # Agent 身份设定
+├── SOUL.md # Agent 灵魂/性格
+├── TOOLS.md # 工具描述
+└── USER.md # 用户偏好
+
+```
+
+### 心跳 / 周期性任务 (Heartbeat)
+
+PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md` 文件:
+
+```markdown
+# Periodic Tasks
+
+- Check my email for important messages
+- Review my calendar for upcoming events
+- Check the weather forecast
+
+```
+
+Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
+
+#### 使用 Spawn 的异步任务
+
+对于耗时较长的任务(网络搜索、API 调用),使用 `spawn` 工具创建一个 **子 Agent (subagent)**:
+
+```markdown
+# Periodic Tasks
+
+## Quick Tasks (respond directly)
+- Report current time
+
+## Long Tasks (use spawn for async)
+- Search the web for AI news and summarize
+- Check email and report important messages
+
+```
+
+**关键行为:**
+
+| 特性 | 描述 |
+| --- | --- |
+| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
+| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
+| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
+| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
+
+#### 子 Agent 通信原理
+
+```
+心跳触发 (Heartbeat triggers)
+ ↓
+Agent 读取 HEARTBEAT.md
+ ↓
+对于长任务: spawn 子 Agent
+ ↓ ↓
+继续下一个任务 子 Agent 独立工作
+ ↓ ↓
+所有任务完成 子 Agent 使用 "message" 工具
+ ↓ ↓
+响应 HEARTBEAT_OK 用户直接收到结果
+
+```
+
+子 Agent 可以访问工具(message, web_search 等),并且无需通过主 Agent 即可独立与用户通信。
+
+**配置:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+
+```
+
+| 选项 | 默认值 | 描述 |
+| --- | --- | --- |
+| `enabled` | `true` | 启用/禁用心跳 |
+| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
+
+**环境变量:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
+
+### 提供商 (Providers)
+
+> [!NOTE]
+> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
+
+| 提供商 | 用途 | 获取 API Key |
+| --- | --- | --- |
+| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
+
+
+智谱 (Zhipu) 配置示例
+
+**1. 获取 API key 和 base URL**
+
+* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+
+**2. 配置**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Your API Key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ },
+ },
+}
+
+```
+
+**3. 运行**
+
+```bash
+picoclaw agent -m "你好"
+
+```
+
+
+
+
+完整配置示例
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "",
+ "app_secret": "",
+ "allow_from": []
+ }
+ },
+ "tools": {
+ "web": {
+ "search": {
+ "api_key": "BSA..."
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+
+```
+
+
+
+## CLI 命令行参考
+
+| 命令 | 描述 |
+| --- | --- |
+| `picoclaw onboard` | 初始化配置和工作区 |
+| `picoclaw agent -m "..."` | 与 Agent 对话 |
+| `picoclaw agent` | 交互式聊天模式 |
+| `picoclaw gateway` | 启动网关 (Gateway) |
+| `picoclaw status` | 显示状态 |
+| `picoclaw cron list` | 列出所有定时任务 |
+| `picoclaw cron add ...` | 添加定时任务 |
+
+### 定时任务 / 提醒 (Scheduled Tasks)
+
+PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
+
+* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
+* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
+* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
+
+任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
+
+## 🤝 贡献与路线图 (Roadmap)
+
+欢迎提交 PR!代码库刻意保持小巧和可读。🤗
+
+路线图即将发布...
+
+开发者群组正在组建中,入群门槛:至少合并过 1 个 PR。
+
+用户群组:
+
+Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
+
+
+
+## 🐛 疑难解答 (Troubleshooting)
+
+### 网络搜索提示 "API 配置问题"
+
+如果您尚未配置搜索 API Key,这是正常的。PicoClaw 会提供手动搜索的帮助链接。
+
+启用网络搜索:
+
+1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
+2. 添加到 `~/.picoclaw/config.json`:
+```json
+{
+ "tools": {
+ "web": {
+ "search": {
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ }
+ }
+ }
+}
+
+```
+
+
+
+### 遇到内容过滤错误 (Content Filtering Errors)
+
+某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
+
+### Telegram bot 提示 "Conflict: terminated by other getUpdates"
+
+这表示有另一个机器人实例正在运行。请确保同一时间只有一个 `picoclaw gateway` 进程在运行。
+
+---
+
+## 📝 API Key 对比
+
+| 服务 | 免费层级 | 适用场景 |
+| --- | --- | --- |
+| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
+| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
+| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
+| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
\ No newline at end of file
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 000000000..8c5c0e252
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,116 @@
+
+# 🦐 PicoClaw Roadmap
+
+> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity
+
+---
+
+## 🚀 1. Core Optimization: Extreme Lightweight
+
+*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.*
+
+* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346)
+ * **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB.
+ * **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size.
+ * **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures.
+
+
+## 🛡️ 2. Security Hardening: Defense in Depth
+
+*Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.*
+
+* **Input Defense & Permission Control**
+ * **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation.
+ * **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries.
+ * **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services).
+
+
+* **Sandboxing & Isolation**
+ * **Filesystem Sandbox**: Restrict file R/W operations to specific directories only.
+ * **Context Isolation**: Prevent data leakage between different user sessions or channels.
+ * **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs.
+
+
+* **Authentication & Secrets**
+ * **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage.
+ * **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows.
+
+
+
+## 🔌 3. Connectivity: Protocol-First Architecture
+
+*Connect every model, reach every platform.*
+
+* **Provider**
+ * [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)*
+ * **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference).
+ * **Online Models**: Continued support for frontier closed-source models.
+
+
+* **Channel**
+ * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
+ * **Standards**: Support for the **OneBot** protocol.
+ * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.
+
+
+* **Skill Marketplace**
+ * [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries.
+
+
+
+## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI
+
+*Beyond conversation—focusing on action and collaboration.*
+
+* **Operations**
+ * [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**.
+ * [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook.
+ * [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop).
+
+
+* **Multi-Agent Collaboration**
+ * [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement
+ * [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart).
+ * [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network.
+ * [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms.
+
+
+
+## 📚 5. Developer Experience (DevEx) & Documentation
+
+*Lowering the barrier to entry so anyone can deploy in minutes.*
+
+* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350)
+ * Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step.
+
+
+* **Comprehensive Documentation**
+ * **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android.
+ * **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels.
+ * **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations).
+
+
+
+## 🤖 6. Engineering: AI-Powered Open Source
+
+*Born from Vibe Coding, we continue to use AI to accelerate development.*
+
+* **AI-Enhanced CI/CD**
+ * Integrate AI for automated Code Review, Linting, and PR Labeling.
+ * **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean.
+ * **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes.
+
+
+
+## 🎨 7. Brand & Community
+
+* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design!
+ * *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes."
+
+
+
+---
+
+### 🤝 Call for Contributions
+
+We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together!
\ No newline at end of file
diff --git a/assets/termux.jpg b/assets/termux.jpg
new file mode 100644
index 000000000..30c724a20
Binary files /dev/null and b/assets/termux.jpg differ
diff --git a/assets/wechat.png b/assets/wechat.png
index 0f97fa3ee..6e6f50115 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 61a0c1d94..b893cb834 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -9,8 +9,11 @@ package main
import (
"bufio"
"context"
+ "embed"
"fmt"
"io"
+ "io/fs"
+ "net/http"
"os"
"os/signal"
"path/filepath"
@@ -27,6 +30,7 @@ import (
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices"
picofantasy "github.com/sipeed/picoclaw/pkg/fantasy"
+ "github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/migrate"
@@ -36,6 +40,10 @@ import (
"github.com/sipeed/picoclaw/pkg/voice"
)
+//go:generate cp -r ../../workspace .
+//go:embed workspace
+var embeddedFiles embed.FS
+
var (
version = "dev"
gitCommit string
@@ -229,10 +237,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)
@@ -242,170 +246,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)
}
}
@@ -683,7 +574,8 @@ func gatewayCmd() {
})
// Setup cron tool and service
- cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath())
+ execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
+ cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout)
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
@@ -715,6 +607,9 @@ func gatewayCmd() {
os.Exit(1)
}
+ // Inject channel manager into agent loop for command handling
+ agentLoop.SetChannelManager(channelManager)
+
var transcriber *voice.GroqTranscriber
if cfg.Providers.Groq.APIKey != "" {
transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
@@ -781,6 +676,14 @@ func gatewayCmd() {
fmt.Printf("Error starting channels: %v\n", err)
}
+ healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
+ go func() {
+ if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
+ logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()})
+ }
+ }()
+ fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
+
go agentLoop.Run(ctx)
sigChan := make(chan os.Signal, 1)
@@ -789,6 +692,7 @@ func gatewayCmd() {
fmt.Println("\nShutting down...")
cancel()
+ healthServer.Stop(context.Background())
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
@@ -1096,14 +1000,14 @@ func getConfigPath() string {
return filepath.Join(home, ".picoclaw", "config.json")
}
-func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService {
+func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool
- cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace)
+ cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout)
agentLoop.RegisterTool(cronTool)
// Set the onJob handler
diff --git a/config/config.example.json b/config/config.example.json
index 288e16c58..37c2bcd81 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -14,7 +14,9 @@
"enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"proxy": "",
- "allow_from": ["YOUR_USER_ID"]
+ "allow_from": [
+ "YOUR_USER_ID"
+ ]
},
"discord": {
"enabled": false,
@@ -60,6 +62,14 @@
"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": {
@@ -69,7 +79,8 @@
},
"openai": {
"api_key": "",
- "api_base": ""
+ "api_base": "",
+ "web_search": true
},
"openrouter": {
"api_key": "sk-or-v1-xxx",
@@ -99,14 +110,27 @@
"moonshot": {
"api_key": "sk-xxx",
"api_base": ""
+ },
+ "ollama": {
+ "api_key": "",
+ "api_base": "http://localhost:11434/v1"
}
},
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "perplexity": {
+ "enabled": false,
+ "api_key": "pplx-xxx",
+ "max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
diff --git a/docker-compose.yml b/docker-compose.yml
index 48769627c..32e8ee339 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -11,8 +11,8 @@ services:
profiles:
- agent
volumes:
- - ./config/config.json:/root/.picoclaw/config.json:ro
- - picoclaw-workspace:/root/.picoclaw/workspace
+ - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
+ - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
entrypoint: ["picoclaw", "agent"]
stdin_open: true
tty: true
@@ -31,9 +31,9 @@ services:
- gateway
volumes:
# Configuration file
- - ./config/config.json:/root/.picoclaw/config.json:ro
+ - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
# Persistent workspace (sessions, memory, logs)
- - picoclaw-workspace:/root/.picoclaw/workspace
+ - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
command: ["gateway"]
volumes:
diff --git a/docs/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md
new file mode 100644
index 000000000..cfcc30f17
--- /dev/null
+++ b/docs/picoclaw_community_roadmap_260216.md
@@ -0,0 +1,112 @@
+## 🚀 Join the PicoClaw Journey: Call for Community Volunteers & Roadmap Reveal
+
+**Hello, PicoClaw Community!**
+
+First, a massive thank you to everyone for your enthusiasm and PR contributions. It is because of you that PicoClaw continues to iterate and evolve so rapidly. Thanks to the simplicity and accessibility of the **Go language**, we’ve seen a non-stop stream of high-quality PRs!
+
+PicoClaw is growing much faster than we anticipated. As we are currently in the midst of the **Chinese New Year holiday**, we are looking to recruit community volunteers to help us maintain this incredible momentum.
+
+This document outlines the specific volunteer roles we need right now and provides a look at our upcoming **Roadmap**.
+
+### 🎁 Community Perks
+
+To show our appreciation, developers who officially join our community operations will receive:
+
+* **Exclusive AI Hardware:** Our upcoming, unreleased AI device.
+* **Token Discounts:** Potential discounts on LLM tokens (currently in negotiations with major providers).
+
+### 🎥 Calling All Content Creators!
+
+Not a developer? You can still help! We welcome users to post **PicoClaw reviews or tutorials**.
+
+* **Twitter:** Use the tag **#picoclaw** and mention **@SipeedIO**.
+* **Bilibili:** Mention **@Sipeed矽速科技** or send us a DM.
+We will be rewarding high-quality content creators with the same perks as our community developers!
+
+---
+
+## 🛠️ Urgent Volunteer Roles
+
+We are looking for experts in the following areas:
+
+1. **Issue/PR Reviewers**
+* **The Mission:** With PRs and Issues exploding in volume, we need help with initial triage, evaluation, and merging.
+* **Focus:** Preliminary merging and community health. Efficiency optimization and security audits will be handled by specialized roles.
+
+
+2. **Resource Optimization Experts**
+* **The Mission:** Rapid growth has introduced dependencies that are making PicoClaw a bit "heavy." We want to keep it lean.
+* **Focus:** Analyzing resource growth between releases and trimming redundancy.
+* **Priority:** **RAM usage optimization** > Binary size reduction.
+
+
+3. **Security Audit & Bug Fixes**
+* **The Mission:** Due to the "vibe coding" nature of our early stages, we need a thorough review of network security and AI permission management.
+* **Focus:** Auditing the codebase for vulnerabilities and implementing robust fixes.
+
+
+4. **Documentation & DX (Developer Experience)**
+* **The Mission:** Our current README is a bit outdated. We need "step-by-step" guides that even beginners can follow.
+* **Focus:** Creating clear, user-friendly documentation for both setup and development.
+
+
+5. **AI-Powered CI/CD Optimization**
+* **The Mission:** PicoClaw started as a "vibe coding" experiment; now we want to use AI to manage it.
+* **Focus:** Automating builds with AI and exploring AI-driven issue resolution.
+
+**How to Apply:** > If you are interested in any of the roles above, please send an email to support@sipeed.com with the subject line: [Apply: PicoClaw Expert Volunteer] + Your Desired Role.
+Please include a brief introduction and any relevant experience or portfolio links. We will review all applications and grant project permissions to selected contributors!
+
+---
+
+## 📍 The Roadmap
+
+Interested in a specific feature? You can "claim" these tasks and start building:
+
+###
+* **Provider:**
+ * **Provider Refactor:** Currently being handled by **@Daming** (ETA: 5 days)
+ * You can still submit code; Daming will merge it into the new implementation.
+* **Channels:**
+ * Support for OneBot, additional platforms
+ * attachments (images, audio, video, files).
+* **Skills:**
+ * Implementing `find_skill` to discover tools via [openclaw/skills](https://github.com/openclaw/skills) and other platforms.
+* **Operations:** * MCP Support.
+ * Android operations (e.g., botdrop).
+ * Browser automation via CDP or ActionBook.
+
+
+* **Multi-Agent Ecosystem:**
+ * **Basic Model-Agnet** S
+ * **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
+ * **Swarm Mode.**
+ * **AIEOS Integration.**
+
+
+* **Branding:**
+ * **Logo**: We need a cute logo! We’re leaning toward a **Mantis Shrimp**—small, but packs a legendary punch!
+
+
+We have officially created these tasks as GitHub Issues, all marked with the roadmap tag.
+This list will be updated continuously as we progress.
+If you would like to claim a task, please feel free to start a conversation by commenting directly on the corresponding issue!
+
+---
+
+## 🤝 How to Join
+
+**Everything is open to your creativity!** If you have a wild idea, just PR it.
+
+1. **The Fast Track:** Once you have at least **one merged PR**, you are eligible to join our **Developer Discord** to help plan the future of PicoClaw.
+2. **The Application Track:** If you haven’t submitted a PR yet but want to dive in, email **support@sipeed.com** with the subject:
+> `[Apply Join PicoClaw Dev Group] + Your GitHub Account`
+> Include the role you're interested in and any evidence of your development experience.
+
+
+
+### Looking Ahead
+
+Powered by PicoClaw, we are crafting a Swarm AI Assistant to transform your environment into a seamless network of personal stewards. By automating the friction of daily life, we empower you to transcend the ordinary and freely explore your creative potential.
+
+**Finally, Happy Chinese New Year to everyone!** May PicoClaw gallop forward in this **Year of the Horse!** 🐎
diff --git a/go.mod b/go.mod
index 83a973b10..93e680083 100644
--- a/go.mod
+++ b/go.mod
@@ -24,6 +24,12 @@ require (
golang.org/x/oauth2 v0.35.0
)
+require (
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
+
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
@@ -33,7 +39,6 @@ require (
github.com/charmbracelet/x/exp/slice v0.0.0-20250904123553-b4e2667e5ad5 // indirect
github.com/charmbracelet/x/json v0.2.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
- github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dlclark/regexp2 v1.10.0 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
@@ -49,7 +54,6 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect
github.com/pkoukk/tiktoken-go v0.1.6 // indirect
- github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -70,5 +74,4 @@ require (
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 0ea9b74a0..39d54134c 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -15,9 +15,11 @@ import (
"sync"
"sync/atomic"
"time"
+ "unicode/utf8"
fantasy "charm.land/fantasy"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
picofantasy "github.com/sipeed/picoclaw/pkg/fantasy"
@@ -47,6 +49,7 @@ type AgentLoop struct {
summarizing sync.Map // Tracks which sessions are currently being summarized
summarizeFailures sync.Map // Tracks consecutive summarization failures per session (string -> int)
cfg *config.Config // Stored for subagent factory access
+ channelManager *channels.Manager
}
// processOptions configures how a message is processed
@@ -83,6 +86,9 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
BraveEnabled: cfg.Tools.Web.Brave.Enabled,
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
+ PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
+ PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
+ PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
}); searchTool != nil {
registry.Register(searchTool)
}
@@ -256,6 +262,10 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) {
al.tools.Register(tool)
}
+func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
+ al.channelManager = cm
+}
+
// RecordLastChannel records the last active channel for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChannel(channel string) error {
@@ -343,6 +353,11 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.processSystemMessage(ctx, msg)
}
+ // Check for commands
+ if response, handled := al.handleCommand(ctx, msg); handled {
+ return response, nil
+ }
+
// Process as user message
return al.runAgentLoop(ctx, processOptions{
SessionKey: msg.SessionKey,
@@ -525,7 +540,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
// 13. Optional: summarization
if opts.EnableSummary {
- al.maybeSummarize(opts.SessionKey)
+ al.maybeSummarize(opts.SessionKey, opts.Channel, opts.ChatID)
}
// 14. Optional: send response via bus
@@ -677,7 +692,7 @@ func (al *AgentLoop) runAgentLoopStreaming(ctx context.Context, opts processOpti
// 12. Summarization
if opts.EnableSummary {
- al.maybeSummarize(opts.SessionKey)
+ al.maybeSummarize(opts.SessionKey, opts.Channel, opts.ChatID)
}
// 13. Log response
@@ -717,7 +732,7 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) {
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
-func (al *AgentLoop) maybeSummarize(sessionKey string) {
+func (al *AgentLoop) maybeSummarize(sessionKey, channel, chatID string) {
newHistory := al.sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory)
threshold := al.contextWindow * 75 / 100
@@ -726,12 +741,80 @@ func (al *AgentLoop) maybeSummarize(sessionKey string) {
if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading {
go func() {
defer al.summarizing.Delete(sessionKey)
+ // Notify user about optimization if not an internal channel
+ if !constants.IsInternalChannel(channel) {
+ al.bus.PublishOutbound(bus.OutboundMessage{
+ Channel: channel,
+ ChatID: chatID,
+ Content: "⚠️ Memory threshold reached. Optimizing conversation history...",
+ })
+ }
al.summarizeSession(sessionKey)
}()
}
}
}
+// forceCompression aggressively reduces context when the limit is hit.
+// It drops the oldest 50% of messages (keeping system prompt and last user message).
+func (al *AgentLoop) forceCompression(sessionKey string) {
+ history := al.sessions.GetHistory(sessionKey)
+ if len(history) <= 4 {
+ return
+ }
+
+ // Keep system prompt (usually [0]) and the very last message (user's trigger)
+ // We want to drop the oldest half of the *conversation*
+ // Assuming [0] is system, [1:] is conversation
+ conversation := history[1 : len(history)-1]
+ if len(conversation) == 0 {
+ return
+ }
+
+ // Helper to find the mid-point of the conversation
+ mid := len(conversation) / 2
+
+ // New history structure:
+ // 1. System Prompt
+ // 2. [Summary of dropped part] - synthesized
+ // 3. Second half of conversation
+ // 4. Last message
+
+ // Simplified approach for emergency: Drop first half of conversation
+ // and rely on existing summary if present, or create a placeholder.
+
+ droppedCount := mid
+ keptConversation := conversation[mid:]
+
+ newHistory := make([]messages.Message, 0)
+ newHistory = append(newHistory, history[0]) // System prompt
+
+ // Add a note about compression
+ compressionNote := fmt.Sprintf("[System: Emergency compression dropped %d oldest messages due to context limit]", droppedCount)
+ // If there was an existing summary, we might lose it if it was in the dropped part (which is just messages).
+ // The summary is stored separately in session.Summary, so it persists!
+ // We just need to ensure the user knows there's a gap.
+
+ // We only modify the messages list here
+ newHistory = append(newHistory, messages.Message{
+ Role: "system",
+ Content: compressionNote,
+ })
+
+ newHistory = append(newHistory, keptConversation...)
+ newHistory = append(newHistory, history[len(history)-1]) // Last message
+
+ // Update session
+ al.sessions.SetHistory(sessionKey, newHistory)
+ al.sessions.Save(sessionKey)
+
+ logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{
+ "session_key": sessionKey,
+ "dropped_msgs": droppedCount,
+ "new_count": len(newHistory),
+ })
+}
+
// GetStartupInfo returns information about loaded tools and skills for logging.
func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
info := make(map[string]interface{})
@@ -806,7 +889,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
if m.Role != "user" && m.Role != "assistant" {
continue
}
- msgTokens := len(m.Content) / 4
+ msgTokens := len(m.Content) / 2
if msgTokens > maxMessageTokens {
omitted = true
continue
@@ -908,9 +991,92 @@ func (al *AgentLoop) callModel(ctx context.Context, prompt string) (string, erro
// estimateTokens estimates the number of tokens in a message list.
func (al *AgentLoop) estimateTokens(msgs []messages.Message) int {
- total := 0
+ totalChars := 0
for _, m := range msgs {
- total += len(m.Content) / 4 // Simple heuristic: 4 chars per token
+ totalChars += utf8.RuneCountInString(m.Content)
}
- return total
+ return totalChars * 2 / 5
+}
+
+func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
+ content := strings.TrimSpace(msg.Content)
+ if !strings.HasPrefix(content, "/") {
+ return "", false
+ }
+
+ parts := strings.Fields(content)
+ if len(parts) == 0 {
+ return "", false
+ }
+
+ cmd := parts[0]
+ args := parts[1:]
+
+ switch cmd {
+ case "/show":
+ if len(args) < 1 {
+ return "Usage: /show [model|channel]", true
+ }
+ switch args[0] {
+ case "model":
+ return fmt.Sprintf("Current model: %s", al.model), true
+ case "channel":
+ return fmt.Sprintf("Current channel: %s", msg.Channel), true
+ default:
+ return fmt.Sprintf("Unknown show target: %s", args[0]), true
+ }
+
+ case "/list":
+ if len(args) < 1 {
+ return "Usage: /list [models|channels]", true
+ }
+ switch args[0] {
+ case "models":
+ // TODO: Fetch available models dynamically if possible
+ return "Available models: glm-4.7, claude-3-5-sonnet, gpt-4o (configured in config.json/env)", true
+ case "channels":
+ if al.channelManager == nil {
+ return "Channel manager not initialized", true
+ }
+ channels := al.channelManager.GetEnabledChannels()
+ if len(channels) == 0 {
+ return "No channels enabled", true
+ }
+ return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
+ default:
+ return fmt.Sprintf("Unknown list target: %s", args[0]), true
+ }
+
+ case "/switch":
+ if len(args) < 3 || args[1] != "to" {
+ return "Usage: /switch [model|channel] to ", true
+ }
+ target := args[0]
+ value := args[2]
+
+ switch target {
+ case "model":
+ oldModel := al.model
+ al.model = value
+ return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
+ case "channel":
+ // This changes the 'default' channel for some operations, or effectively redirects output?
+ // For now, let's just validate if the channel exists
+ if al.channelManager == nil {
+ return "Channel manager not initialized", true
+ }
+ if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
+ return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
+ }
+
+ // If message came from CLI, maybe we want to redirect CLI output to this channel?
+ // That would require state persistence about "redirected channel"
+ // For now, just acknowledged.
+ return fmt.Sprintf("Switched target channel to %s (Note: this currently only validates existence)", value), true
+ default:
+ return fmt.Sprintf("Unknown switch target: %s", target), true
+ }
+ }
+
+ return "", false
}
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index ecd9ba265..dcd91bebd 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -19,18 +19,20 @@ import (
)
type OAuthProviderConfig struct {
- Issuer string
- ClientID string
- Scopes string
- Port int
+ Issuer string
+ ClientID string
+ Scopes string
+ Originator string
+ Port int
}
func OpenAIOAuthConfig() OAuthProviderConfig {
return OAuthProviderConfig{
- Issuer: "https://auth.openai.com",
- ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
- Scopes: "openid profile email offline_access",
- Port: 1455,
+ Issuer: "https://auth.openai.com",
+ ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
+ Scopes: "openid profile email offline_access",
+ Originator: "codex_cli_rs",
+ Port: 1455,
}
}
@@ -279,7 +281,17 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
return nil, fmt.Errorf("token refresh failed: %s", string(body))
}
- return parseTokenResponse(body, cred.Provider)
+ refreshed, err := parseTokenResponse(body, cred.Provider)
+ if err != nil {
+ return nil, err
+ }
+ if refreshed.RefreshToken == "" {
+ refreshed.RefreshToken = cred.RefreshToken
+ }
+ if refreshed.AccountID == "" {
+ refreshed.AccountID = cred.AccountID
+ }
+ return refreshed, nil
}
func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
@@ -288,15 +300,23 @@ func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
params := url.Values{
- "response_type": {"code"},
- "client_id": {cfg.ClientID},
- "redirect_uri": {redirectURI},
- "scope": {cfg.Scopes},
- "code_challenge": {pkce.CodeChallenge},
- "code_challenge_method": {"S256"},
- "state": {state},
+ "response_type": {"code"},
+ "client_id": {cfg.ClientID},
+ "redirect_uri": {redirectURI},
+ "scope": {cfg.Scopes},
+ "code_challenge": {pkce.CodeChallenge},
+ "code_challenge_method": {"S256"},
+ "id_token_add_organizations": {"true"},
+ "codex_cli_simplified_flow": {"true"},
+ "state": {state},
}
- return cfg.Issuer + "/authorize?" + params.Encode()
+ if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
+ params.Set("originator", "picoclaw")
+ }
+ if cfg.Originator != "" {
+ params.Set("originator", cfg.Originator)
+ }
+ return cfg.Issuer + "/oauth/authorize?" + params.Encode()
}
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
@@ -350,19 +370,57 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
AuthMethod: "oauth",
}
- if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
+ if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
+ cred.AccountID = accountID
+ } else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
+ cred.AccountID = accountID
+ } else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
+ // Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims.
cred.AccountID = accountID
}
return cred, nil
}
-func extractAccountID(accessToken string) string {
- parts := strings.Split(accessToken, ".")
- if len(parts) < 2 {
+func extractAccountID(token string) string {
+ claims, err := parseJWTClaims(token)
+ if err != nil {
return ""
}
+ if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" {
+ return accountID
+ }
+
+ if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" {
+ return accountID
+ }
+
+ if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
+ if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
+ return accountID
+ }
+ }
+
+ if orgs, ok := claims["organizations"].([]interface{}); ok {
+ for _, org := range orgs {
+ if orgMap, ok := org.(map[string]interface{}); ok {
+ if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
+ return accountID
+ }
+ }
+ }
+ }
+
+ return ""
+}
+
+func parseJWTClaims(token string) (map[string]interface{}, error) {
+ parts := strings.Split(token, ".")
+ if len(parts) < 2 {
+ return nil, fmt.Errorf("token is not a JWT")
+ }
+
payload := parts[1]
switch len(payload) % 4 {
case 2:
@@ -373,21 +431,15 @@ func extractAccountID(accessToken string) string {
decoded, err := base64URLDecode(payload)
if err != nil {
- return ""
+ return nil, err
}
var claims map[string]interface{}
if err := json.Unmarshal(decoded, &claims); err != nil {
- return ""
+ return nil, err
}
- if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
- if accountID, ok := authClaim["chatgpt_account_id"].(string); ok {
- return accountID
- }
- }
-
- return ""
+ return claims, nil
}
func base64URLDecode(s string) ([]byte, error) {
diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go
index 9f8013217..5deb17805 100644
--- a/pkg/auth/oauth_test.go
+++ b/pkg/auth/oauth_test.go
@@ -1,19 +1,34 @@
package auth
import (
+ "encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
)
+func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
+ t.Helper()
+
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
+ payloadJSON, err := json.Marshal(claims)
+ if err != nil {
+ t.Fatalf("marshal claims: %v", err)
+ }
+ payload := base64.RawURLEncoding.EncodeToString(payloadJSON)
+ return header + "." + payload + ".sig"
+}
+
func TestBuildAuthorizeURL(t *testing.T) {
cfg := OAuthProviderConfig{
- Issuer: "https://auth.example.com",
- ClientID: "test-client-id",
- Scopes: "openid profile",
- Port: 1455,
+ Issuer: "https://auth.example.com",
+ ClientID: "test-client-id",
+ Scopes: "openid profile",
+ Originator: "codex_cli_rs",
+ Port: 1455,
}
pkce := PKCECodes{
CodeVerifier: "test-verifier",
@@ -22,7 +37,7 @@ func TestBuildAuthorizeURL(t *testing.T) {
u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
- if !strings.HasPrefix(u, "https://auth.example.com/authorize?") {
+ if !strings.HasPrefix(u, "https://auth.example.com/oauth/authorize?") {
t.Errorf("URL does not start with expected prefix: %s", u)
}
if !strings.Contains(u, "client_id=test-client-id") {
@@ -40,6 +55,37 @@ func TestBuildAuthorizeURL(t *testing.T) {
if !strings.Contains(u, "response_type=code") {
t.Error("URL missing response_type")
}
+ if !strings.Contains(u, "id_token_add_organizations=true") {
+ t.Error("URL missing id_token_add_organizations")
+ }
+ if !strings.Contains(u, "codex_cli_simplified_flow=true") {
+ t.Error("URL missing codex_cli_simplified_flow")
+ }
+ if !strings.Contains(u, "originator=codex_cli_rs") {
+ t.Error("URL missing originator")
+ }
+}
+
+func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
+ cfg := OpenAIOAuthConfig()
+ pkce := PKCECodes{CodeVerifier: "test-verifier", CodeChallenge: "test-challenge"}
+
+ u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
+ parsed, err := url.Parse(u)
+ if err != nil {
+ t.Fatalf("url.Parse() error: %v", err)
+ }
+ q := parsed.Query()
+
+ if q.Get("id_token_add_organizations") != "true" {
+ t.Errorf("id_token_add_organizations = %q, want true", q.Get("id_token_add_organizations"))
+ }
+ if q.Get("codex_cli_simplified_flow") != "true" {
+ t.Errorf("codex_cli_simplified_flow = %q, want true", q.Get("codex_cli_simplified_flow"))
+ }
+ if q.Get("originator") != "codex_cli_rs" {
+ t.Errorf("originator = %q, want codex_cli_rs", q.Get("originator"))
+ }
}
func TestParseTokenResponse(t *testing.T) {
@@ -73,6 +119,37 @@ func TestParseTokenResponse(t *testing.T) {
}
}
+func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
+ idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"})
+ resp := map[string]interface{}{
+ "access_token": "opaque-access-token",
+ "refresh_token": "test-refresh-token",
+ "expires_in": 3600,
+ "id_token": idToken,
+ }
+ body, _ := json.Marshal(resp)
+
+ cred, err := parseTokenResponse(body, "openai")
+ if err != nil {
+ t.Fatalf("parseTokenResponse() error: %v", err)
+ }
+ if cred.AccountID != "acc-id-from-id-token" {
+ t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-id-from-id-token")
+ }
+}
+
+func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
+ token := makeJWTForClaims(t, map[string]interface{}{
+ "organizations": []interface{}{
+ map[string]interface{}{"id": "org_from_orgs"},
+ },
+ })
+
+ if got := extractAccountID(token); got != "org_from_orgs" {
+ t.Errorf("extractAccountID() = %q, want %q", got, "org_from_orgs")
+ }
+}
+
func TestParseTokenResponseNoAccessToken(t *testing.T) {
body := []byte(`{"refresh_token": "test"}`)
_, err := parseTokenResponse(body, "openai")
@@ -81,6 +158,32 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) {
}
}
+func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
+ idToken := makeJWTWithAccountID("acc-from-id")
+ resp := map[string]interface{}{
+ "access_token": "not-a-jwt",
+ "refresh_token": "test-refresh-token",
+ "expires_in": 3600,
+ "id_token": idToken,
+ }
+ body, _ := json.Marshal(resp)
+
+ cred, err := parseTokenResponse(body, "openai")
+ if err != nil {
+ t.Fatalf("parseTokenResponse() error: %v", err)
+ }
+
+ if cred.AccountID != "acc-from-id" {
+ t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-from-id")
+ }
+}
+
+func makeJWTWithAccountID(accountID string) string {
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`))
+ return header + "." + payload + ".sig"
+}
+
func TestExchangeCodeForTokens(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth/token" {
@@ -185,6 +288,37 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
}
}
+func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]interface{}{
+ "access_token": "new-access-token-only",
+ "expires_in": 3600,
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ cfg := OAuthProviderConfig{Issuer: server.URL, ClientID: "test-client"}
+ cred := &AuthCredential{
+ AccessToken: "old-access",
+ RefreshToken: "existing-refresh",
+ AccountID: "acc_existing",
+ Provider: "openai",
+ AuthMethod: "oauth",
+ }
+
+ refreshed, err := RefreshAccessToken(cred, cfg)
+ if err != nil {
+ t.Fatalf("RefreshAccessToken() error: %v", err)
+ }
+ if refreshed.RefreshToken != "existing-refresh" {
+ t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "existing-refresh")
+ }
+ if refreshed.AccountID != "acc_existing" {
+ t.Errorf("AccountID = %q, want %q", refreshed.AccountID, "acc_existing")
+ }
+}
+
func TestOpenAIOAuthConfig(t *testing.T) {
cfg := OpenAIOAuthConfig()
if cfg.Issuer != "https://auth.openai.com" {
diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go
index 1fcb0f130..04f94a30e 100644
--- a/pkg/bus/bus.go
+++ b/pkg/bus/bus.go
@@ -11,6 +11,7 @@ type MessageBus struct {
inbound chan InboundMessage
outbound chan OutboundMessage
handlers map[string]MessageHandler
+ closed bool
mu sync.RWMutex
}
@@ -23,6 +24,11 @@ func NewMessageBus() *MessageBus {
}
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
+ mb.mu.RLock()
+ defer mb.mu.RUnlock()
+ if mb.closed {
+ return
+ }
mb.inbound <- msg
}
@@ -52,6 +58,11 @@ func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool)
}
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
+ mb.mu.RLock()
+ defer mb.mu.RUnlock()
+ if mb.closed {
+ return
+ }
mb.outbound <- msg
}
@@ -78,6 +89,12 @@ func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
}
func (mb *MessageBus) Close() {
+ mb.mu.Lock()
+ defer mb.mu.Unlock()
+ if mb.closed {
+ return
+ }
+ mb.closed = true
close(mb.inbound)
close(mb.outbound)
}
diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go
index e65c99eec..00aa8ab4d 100644
--- a/pkg/channels/discord.go
+++ b/pkg/channels/discord.go
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
+ "strings"
"time"
"github.com/bwmarrin/discordgo"
@@ -100,15 +101,156 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("channel ID is empty")
}
- message := msg.Content
+ runes := []rune(msg.Content)
+ if len(runes) == 0 {
+ return nil
+ }
+ chunks := splitMessage(msg.Content, 1500) // Discord has a limit of 2000 characters per message, leave 500 for natural split e.g. code blocks
+
+ for _, chunk := range chunks {
+ if err := c.sendChunk(ctx, channelID, chunk); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+// splitMessage splits long messages into chunks, preserving code block integrity
+// Uses natural boundaries (newlines, spaces) and extends messages slightly to avoid breaking code blocks
+func splitMessage(content string, limit int) []string {
+ var messages []string
+
+ for len(content) > 0 {
+ if len(content) <= limit {
+ messages = append(messages, content)
+ break
+ }
+
+ msgEnd := limit
+
+ // Find natural split point within the limit
+ msgEnd = findLastNewline(content[:limit], 200)
+ if msgEnd <= 0 {
+ msgEnd = findLastSpace(content[:limit], 100)
+ }
+ if msgEnd <= 0 {
+ msgEnd = limit
+ }
+
+ // Check if this would end with an incomplete code block
+ candidate := content[:msgEnd]
+ unclosedIdx := findLastUnclosedCodeBlock(candidate)
+
+ if unclosedIdx >= 0 {
+ // Message would end with incomplete code block
+ // Try to extend to include the closing ``` (with some buffer)
+ extendedLimit := limit + 500 // Allow 500 char buffer for code blocks
+ if len(content) > extendedLimit {
+ closingIdx := findNextClosingCodeBlock(content, msgEnd)
+ if closingIdx > 0 && closingIdx <= extendedLimit {
+ // Extend to include the closing ```
+ msgEnd = closingIdx
+ } else {
+ // Can't find closing, split before the code block
+ msgEnd = findLastNewline(content[:unclosedIdx], 200)
+ if msgEnd <= 0 {
+ msgEnd = findLastSpace(content[:unclosedIdx], 100)
+ }
+ if msgEnd <= 0 {
+ msgEnd = unclosedIdx
+ }
+ }
+ } else {
+ // Remaining content fits within extended limit
+ msgEnd = len(content)
+ }
+ }
+
+ if msgEnd <= 0 {
+ msgEnd = limit
+ }
+
+ messages = append(messages, content[:msgEnd])
+ content = strings.TrimSpace(content[msgEnd:])
+ }
+
+ return messages
+}
+
+// findLastUnclosedCodeBlock finds the last opening ``` that doesn't have a closing ```
+// Returns the position of the opening ``` or -1 if all code blocks are complete
+func findLastUnclosedCodeBlock(text string) int {
+ count := 0
+ lastOpenIdx := -1
+
+ for i := 0; i < len(text); i++ {
+ if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
+ if count == 0 {
+ lastOpenIdx = i
+ }
+ count++
+ i += 2
+ }
+ }
+
+ // If odd number of ``` markers, last one is unclosed
+ if count%2 == 1 {
+ return lastOpenIdx
+ }
+ return -1
+}
+
+// findNextClosingCodeBlock finds the next closing ``` starting from a position
+// Returns the position after the closing ``` or -1 if not found
+func findNextClosingCodeBlock(text string, startIdx int) int {
+ for i := startIdx; i < len(text); i++ {
+ if i+2 < len(text) && text[i] == '`' && text[i+1] == '`' && text[i+2] == '`' {
+ return i + 3
+ }
+ }
+ return -1
+}
+
+// findLastNewline finds the last newline character within the last N characters
+// Returns the position of the newline or -1 if not found
+func findLastNewline(s string, searchWindow int) int {
+ searchStart := len(s) - searchWindow
+ if searchStart < 0 {
+ searchStart = 0
+ }
+ for i := len(s) - 1; i >= searchStart; i-- {
+ if s[i] == '\n' {
+ return i
+ }
+ }
+ return -1
+}
+
+// findLastSpace finds the last space character within the last N characters
+// Returns the position of the space or -1 if not found
+func findLastSpace(s string, searchWindow int) int {
+ searchStart := len(s) - searchWindow
+ if searchStart < 0 {
+ searchStart = 0
+ }
+ for i := len(s) - 1; i >= searchStart; i-- {
+ if s[i] == ' ' || s[i] == '\t' {
+ return i
+ }
+ }
+ return -1
+}
+
+func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
// 使用传入的 ctx 进行超时控制
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
- _, err := c.session.ChannelMessageSend(channelID, message)
+ _, err := c.session.ChannelMessageSend(channelID, content)
done <- err
}()
@@ -140,6 +282,12 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
+ if err := c.session.ChannelTyping(m.ChannelID); err != nil {
+ logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{
+ "error": err.Error(),
+ })
+ }
+
// 检查白名单,避免为被拒绝的用户下载附件和转录
if !c.IsAllowed(m.Author.ID) {
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go
index 27185c0c6..27d66997a 100644
--- a/pkg/channels/maixcam.go
+++ b/pkg/channels/maixcam.go
@@ -18,7 +18,6 @@ type MaixCamChannel struct {
listener net.Listener
clients map[net.Conn]bool
clientsMux sync.RWMutex
- running bool
}
type MaixCamMessage struct {
@@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC
BaseChannel: base,
config: cfg,
clients: make(map[net.Conn]bool),
- running: false,
}, nil
}
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 69e9b2b43..7f6abc4cb 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -48,7 +48,7 @@ func (m *Manager) initChannels() error {
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
logger.DebugC("channels", "Attempting to initialize Telegram channel")
- telegram, err := NewTelegramChannel(m.config.Channels.Telegram, m.bus)
+ telegram, err := NewTelegramChannel(m.config, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{
"error": err.Error(),
@@ -163,6 +163,19 @@ func (m *Manager) initChannels() error {
}
}
+ 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 000000000..5d97fab9c
--- /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/slack.go b/pkg/channels/slack.go
index d86d08a9d..5387e9213 100644
--- a/pkg/channels/slack.go
+++ b/pkg/channels/slack.go
@@ -296,6 +296,13 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
+ if !c.IsAllowed(ev.User) {
+ logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{
+ "user_id": ev.User,
+ })
+ return
+ }
+
senderID := ev.User
channelID := ev.Channel
threadTS := ev.ThreadTimeStamp
@@ -345,6 +352,13 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
c.socketClient.Ack(*event.Request)
}
+ if !c.IsAllowed(cmd.UserID) {
+ logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{
+ "user_id": cmd.UserID,
+ })
+ return
+ }
+
senderID := cmd.UserID
channelID := cmd.ChannelID
chatID := channelID
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index 0934dbd08..5601d508c 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -11,7 +11,10 @@ import (
"sync"
"time"
+ th "github.com/mymmrac/telego/telegohandler"
+
"github.com/mymmrac/telego"
+ "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -24,7 +27,8 @@ import (
type TelegramChannel struct {
*BaseChannel
bot *telego.Bot
- config config.TelegramConfig
+ commands TelegramCommander
+ config *config.Config
chatIDs map[string]int64
transcriber *voice.GroqTranscriber
placeholders sync.Map // chatID -> messageID
@@ -41,13 +45,14 @@ func (c *thinkingCancel) Cancel() {
}
}
-func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
+func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
var opts []telego.BotOption
+ telegramCfg := cfg.Channels.Telegram
- if cfg.Proxy != "" {
- proxyURL, parseErr := url.Parse(cfg.Proxy)
+ if telegramCfg.Proxy != "" {
+ proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
if parseErr != nil {
- return nil, fmt.Errorf("invalid proxy URL %q: %w", cfg.Proxy, parseErr)
+ return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr)
}
opts = append(opts, telego.WithHTTPClient(&http.Client{
Transport: &http.Transport{
@@ -56,15 +61,16 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr
}))
}
- bot, err := telego.NewBot(cfg.Token, opts...)
+ bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
- base := NewBaseChannel("telegram", cfg, bus, cfg.AllowFrom)
+ base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom)
return &TelegramChannel{
BaseChannel: base,
+ commands: NewTelegramCommands(bot, cfg),
bot: bot,
config: cfg,
chatIDs: make(map[string]int64),
@@ -88,31 +94,45 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start long polling: %w", err)
}
+ bh, err := telegohandler.NewBotHandler(c.bot, updates)
+ if err != nil {
+ return fmt.Errorf("failed to create bot handler: %w", err)
+ }
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ c.commands.Help(ctx, message)
+ return nil
+ }, th.CommandEqual("help"))
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.commands.Start(ctx, message)
+ }, th.CommandEqual("start"))
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.commands.Show(ctx, message)
+ }, th.CommandEqual("show"))
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.commands.List(ctx, message)
+ }, th.CommandEqual("list"))
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.handleMessage(ctx, &message)
+ }, th.AnyMessage())
+
c.setRunning(true)
logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{
"username": c.bot.Username(),
})
+ go bh.Start()
+
go func() {
- for {
- select {
- case <-ctx.Done():
- return
- case update, ok := <-updates:
- if !ok {
- logger.InfoC("telegram", "Updates channel closed, reconnecting...")
- return
- }
- if update.Message != nil {
- c.handleMessage(ctx, update)
- }
- }
- }
+ <-ctx.Done()
+ bh.Stop()
}()
return nil
}
-
func (c *TelegramChannel) Stop(ctx context.Context) error {
logger.InfoC("telegram", "Stopping Telegram bot...")
c.setRunning(false)
@@ -166,30 +186,27 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return nil
}
-func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Update) {
- message := update.Message
+func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
if message == nil {
- return
+ return fmt.Errorf("message is nil")
}
user := message.From
if user == nil {
- return
+ return fmt.Errorf("message sender (user) is nil")
}
- userID := fmt.Sprintf("%d", user.ID)
- senderID := userID
+ senderID := fmt.Sprintf("%d", user.ID)
if user.Username != "" {
- senderID = fmt.Sprintf("%s|%s", userID, user.Username)
+ senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
}
// 检查白名单,避免为被拒绝的用户下载附件
- if !c.IsAllowed(userID) && !c.IsAllowed(senderID) {
+ if !c.IsAllowed(senderID) {
logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
- "user_id": userID,
- "username": user.Username,
+ "user_id": senderID,
})
- return
+ return nil
}
chatID := message.Chat.ID
@@ -222,7 +239,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
content += message.Caption
}
- if message.Photo != nil && len(message.Photo) > 0 {
+ if len(message.Photo) > 0 {
photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
@@ -231,7 +248,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
- content += fmt.Sprintf("[image: photo]")
+ content += "[image: photo]"
}
}
@@ -252,7 +269,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
"error": err.Error(),
"path": voicePath,
})
- transcribedText = fmt.Sprintf("[voice (transcription failed)]")
+ transcribedText = "[voice (transcription failed)]"
} else {
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
@@ -260,7 +277,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
})
}
} else {
- transcribedText = fmt.Sprintf("[voice]")
+ transcribedText = "[voice]"
}
if content != "" {
@@ -278,7 +295,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
- content += fmt.Sprintf("[audio]")
+ content += "[audio]"
}
}
@@ -290,7 +307,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
- content += fmt.Sprintf("[file]")
+ content += "[file]"
}
}
@@ -320,37 +337,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{
@@ -361,7 +355,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
- c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
+ c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
+ return nil
}
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go
new file mode 100644
index 000000000..df245e156
--- /dev/null
+++ b/pkg/channels/telegram_commands.go
@@ -0,0 +1,153 @@
+package channels
+
+import (
+ "context"
+ "fmt"
+ "strings"
+
+ "github.com/mymmrac/telego"
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+type TelegramCommander interface {
+ Help(ctx context.Context, message telego.Message) error
+ Start(ctx context.Context, message telego.Message) error
+ Show(ctx context.Context, message telego.Message) error
+ List(ctx context.Context, message telego.Message) error
+}
+
+type cmd struct {
+ bot *telego.Bot
+ config *config.Config
+}
+
+func NewTelegramCommands(bot *telego.Bot, cfg *config.Config) TelegramCommander {
+ return &cmd{
+ bot: bot,
+ config: cfg,
+ }
+}
+
+func commandArgs(text string) string {
+ parts := strings.SplitN(text, " ", 2)
+ if len(parts) < 2 {
+ return ""
+ }
+ return strings.TrimSpace(parts[1])
+}
+func (c *cmd) Help(ctx context.Context, message telego.Message) error {
+ msg := `/start - Start the bot
+/help - Show this help message
+/show [model|channel] - Show current configuration
+/list [models|channels] - List available options
+ `
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: msg,
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+}
+
+func (c *cmd) Start(ctx context.Context, message telego.Message) error {
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: "Hello! I am PicoClaw 🦞",
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+}
+
+func (c *cmd) Show(ctx context.Context, message telego.Message) error {
+ args := commandArgs(message.Text)
+ if args == "" {
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: "Usage: /show [model|channel]",
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+ }
+
+ var response string
+ switch args {
+ case "model":
+ response = fmt.Sprintf("Current Model: %s (Provider: %s)",
+ c.config.Agents.Defaults.Model,
+ c.config.Agents.Defaults.Provider)
+ case "channel":
+ response = "Current Channel: telegram"
+ default:
+ response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args)
+ }
+
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: response,
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+}
+func (c *cmd) List(ctx context.Context, message telego.Message) error {
+ args := commandArgs(message.Text)
+ if args == "" {
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: "Usage: /list [models|channels]",
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+ }
+
+ var response string
+ switch args {
+ case "models":
+ provider := c.config.Agents.Defaults.Provider
+ if provider == "" {
+ provider = "configured default"
+ }
+ response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml",
+ c.config.Agents.Defaults.Model, provider)
+
+ case "channels":
+ var enabled []string
+ if c.config.Channels.Telegram.Enabled {
+ enabled = append(enabled, "telegram")
+ }
+ if c.config.Channels.WhatsApp.Enabled {
+ enabled = append(enabled, "whatsapp")
+ }
+ if c.config.Channels.Feishu.Enabled {
+ enabled = append(enabled, "feishu")
+ }
+ if c.config.Channels.Discord.Enabled {
+ enabled = append(enabled, "discord")
+ }
+ if c.config.Channels.Slack.Enabled {
+ enabled = append(enabled, "slack")
+ }
+ response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- "))
+
+ default:
+ response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args)
+ }
+
+ _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
+ ChatID: telego.ChatID{ID: message.Chat.ID},
+ Text: response,
+ ReplyParameters: &telego.ReplyParameters{
+ MessageID: message.MessageID,
+ },
+ })
+ return err
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 8f9ba1411..8e8b57d8d 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -78,6 +78,7 @@ type ChannelsConfig struct {
DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"`
LINE LINEConfig `json:"line"`
+ OneBot OneBotConfig `json:"onebot"`
}
type WhatsAppConfig struct {
@@ -130,10 +131,10 @@ type DingTalkConfig struct {
}
type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
- AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}
type LINEConfig struct {
@@ -146,6 +147,15 @@ type LINEConfig struct {
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
@@ -157,25 +167,33 @@ type DevicesConfig struct {
}
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"`
- DeepSeek ProviderConfig `json:"deepseek"`
+ Anthropic ProviderConfig `json:"anthropic"`
+ OpenAI OpenAIProviderConfig `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"`
+ Ollama ProviderConfig `json:"ollama"`
+ 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"`
- Timeout int `json:"timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s)
+ 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"`
+ Timeout int `json:"timeout,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_TIMEOUT"` // seconds, 0 = default (120s)
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` // only for Github Copilot, `stdio` or `grpc`
+}
+
+type OpenAIProviderConfig struct {
+ ProviderConfig
+ WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"`
}
type GatewayConfig struct {
@@ -194,14 +212,26 @@ type DuckDuckGoConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
}
+type PerplexityConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
+ APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
+ MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
+}
+
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
+ Perplexity PerplexityConfig `json:"perplexity"`
+}
+
+type CronToolsConfig struct {
+ ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout
}
type ToolsConfig struct {
- Web WebToolsConfig `json:"web"`
- ProgressiveDisclosure bool `json:"progressive_disclosure" env:"PICOCLAW_TOOLS_PROGRESSIVE_DISCLOSURE"`
+ Web WebToolsConfig `json:"web"`
+ ProgressiveDisclosure bool `json:"progressive_disclosure" env:"PICOCLAW_TOOLS_PROGRESSIVE_DISCLOSURE"`
+ Cron CronToolsConfig `json:"cron"`
}
func DefaultConfig() *Config {
@@ -263,7 +293,7 @@ func DefaultConfig() *Config {
Enabled: false,
BotToken: "",
AppToken: "",
- AllowFrom: []string{},
+ AllowFrom: FlexibleStringSlice{},
},
LINE: LINEConfig{
Enabled: false,
@@ -274,18 +304,29 @@ func DefaultConfig() *Config {
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{},
- OpenAI: ProviderConfig{},
- OpenRouter: ProviderConfig{},
- Groq: ProviderConfig{},
- Zhipu: ProviderConfig{},
- VLLM: ProviderConfig{},
- Gemini: ProviderConfig{},
- Nvidia: ProviderConfig{},
- Moonshot: ProviderConfig{},
- ShengSuanYun: ProviderConfig{},
+ Anthropic: ProviderConfig{},
+ OpenAI: OpenAIProviderConfig{WebSearch: true},
+ OpenRouter: ProviderConfig{},
+ Groq: ProviderConfig{},
+ Zhipu: ProviderConfig{},
+ VLLM: ProviderConfig{},
+ Gemini: ProviderConfig{},
+ Nvidia: ProviderConfig{},
+ Ollama: ProviderConfig{},
+ Moonshot: ProviderConfig{},
+ ShengSuanYun: ProviderConfig{},
+ DeepSeek: ProviderConfig{},
+ GitHubCopilot: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
@@ -302,6 +343,15 @@ func DefaultConfig() *Config {
Enabled: true,
MaxResults: 5,
},
+ Perplexity: PerplexityConfig{
+ Enabled: false,
+ APIKey: "",
+ MaxResults: 5,
+ },
+ },
+ ProgressiveDisclosure: false,
+ Cron: CronToolsConfig{
+ ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
},
},
Heartbeat: HeartbeatConfig{
@@ -390,7 +440,7 @@ func SaveConfig(path string, cfg *Config) error {
return err
}
- return os.WriteFile(path, data, 0644)
+ return os.WriteFile(path, data, 0600)
}
func (c *Config) WorkspacePath() string {
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 14618b109..a1f73f0b3 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -1,6 +1,9 @@
package config
import (
+ "os"
+ "path/filepath"
+ "runtime"
"testing"
)
@@ -147,6 +150,30 @@ func TestDefaultConfig_WebTools(t *testing.T) {
}
}
+func TestSaveConfig_FilePermissions(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("file permission bits are not enforced on Windows")
+ }
+
+ tmpDir := t.TempDir()
+ path := filepath.Join(tmpDir, "config.json")
+
+ cfg := DefaultConfig()
+ if err := SaveConfig(path, cfg); err != nil {
+ t.Fatalf("SaveConfig failed: %v", err)
+ }
+
+ info, err := os.Stat(path)
+ if err != nil {
+ t.Fatalf("Stat failed: %v", err)
+ }
+
+ perm := info.Mode().Perm()
+ if perm != 0600 {
+ t.Errorf("config file has permission %04o, want 0600", perm)
+ }
+}
+
// TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig()
@@ -177,3 +204,42 @@ func TestConfig_Complete(t *testing.T) {
t.Error("Heartbeat should be enabled by default")
}
}
+
+func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
+ cfg := DefaultConfig()
+ if !cfg.Providers.OpenAI.WebSearch {
+ t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true")
+ }
+}
+
+func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error: %v", err)
+ }
+ if !cfg.Providers.OpenAI.WebSearch {
+ t.Fatal("OpenAI codex web search should remain true when unset in config file")
+ }
+}
+
+func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
+ dir := t.TempDir()
+ configPath := filepath.Join(dir, "config.json")
+ if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil {
+ t.Fatalf("WriteFile() error: %v", err)
+ }
+
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ t.Fatalf("LoadConfig() error: %v", err)
+ }
+ if cfg.Providers.OpenAI.WebSearch {
+ t.Fatal("OpenAI codex web search should be false when disabled in config file")
+ }
+}
diff --git a/pkg/cron/service.go b/pkg/cron/service.go
index ddd680e74..9f62c743b 100644
--- a/pkg/cron/service.go
+++ b/pkg/cron/service.go
@@ -340,7 +340,7 @@ func (cs *CronService) saveStoreUnsafe() error {
return err
}
- return os.WriteFile(cs.storePath, data, 0644)
+ return os.WriteFile(cs.storePath, data, 0600)
}
func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
diff --git a/pkg/cron/service_test.go b/pkg/cron/service_test.go
new file mode 100644
index 000000000..53d69f6a9
--- /dev/null
+++ b/pkg/cron/service_test.go
@@ -0,0 +1,38 @@
+package cron
+
+import (
+ "os"
+ "path/filepath"
+ "runtime"
+ "testing"
+)
+
+func TestSaveStore_FilePermissions(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("file permission bits are not enforced on Windows")
+ }
+
+ tmpDir := t.TempDir()
+ storePath := filepath.Join(tmpDir, "cron", "jobs.json")
+
+ cs := NewCronService(storePath, nil)
+
+ _, err := cs.AddJob("test", CronSchedule{Kind: "every", EveryMS: int64Ptr(60000)}, "hello", false, "cli", "direct")
+ if err != nil {
+ t.Fatalf("AddJob failed: %v", err)
+ }
+
+ info, err := os.Stat(storePath)
+ if err != nil {
+ t.Fatalf("Stat failed: %v", err)
+ }
+
+ perm := info.Mode().Perm()
+ if perm != 0600 {
+ t.Errorf("cron store has permission %04o, want 0600", perm)
+ }
+}
+
+func int64Ptr(v int64) *int64 {
+ return &v
+}
diff --git a/pkg/health/server.go b/pkg/health/server.go
new file mode 100644
index 000000000..77b36034d
--- /dev/null
+++ b/pkg/health/server.go
@@ -0,0 +1,164 @@
+package health
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "sync"
+ "time"
+)
+
+type Server struct {
+ server *http.Server
+ mu sync.RWMutex
+ ready bool
+ checks map[string]Check
+ startTime time.Time
+}
+
+type Check struct {
+ Name string `json:"name"`
+ Status string `json:"status"`
+ Message string `json:"message,omitempty"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+type StatusResponse struct {
+ Status string `json:"status"`
+ Uptime string `json:"uptime"`
+ Checks map[string]Check `json:"checks,omitempty"`
+}
+
+func NewServer(host string, port int) *Server {
+ mux := http.NewServeMux()
+ s := &Server{
+ ready: false,
+ checks: make(map[string]Check),
+ startTime: time.Now(),
+ }
+
+ mux.HandleFunc("/health", s.healthHandler)
+ mux.HandleFunc("/ready", s.readyHandler)
+
+ addr := fmt.Sprintf("%s:%d", host, port)
+ s.server = &http.Server{
+ Addr: addr,
+ Handler: mux,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
+ }
+
+ return s
+}
+
+func (s *Server) Start() error {
+ s.mu.Lock()
+ s.ready = true
+ s.mu.Unlock()
+ return s.server.ListenAndServe()
+}
+
+func (s *Server) StartContext(ctx context.Context) error {
+ s.mu.Lock()
+ s.ready = true
+ s.mu.Unlock()
+
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- s.server.ListenAndServe()
+ }()
+
+ select {
+ case err := <-errCh:
+ return err
+ case <-ctx.Done():
+ return s.server.Shutdown(context.Background())
+ }
+}
+
+func (s *Server) Stop(ctx context.Context) error {
+ s.mu.Lock()
+ s.ready = false
+ s.mu.Unlock()
+ return s.server.Shutdown(ctx)
+}
+
+func (s *Server) SetReady(ready bool) {
+ s.mu.Lock()
+ s.ready = ready
+ s.mu.Unlock()
+}
+
+func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ status, msg := checkFn()
+ s.checks[name] = Check{
+ Name: name,
+ Status: statusString(status),
+ Message: msg,
+ Timestamp: time.Now(),
+ }
+}
+
+func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+
+ uptime := time.Since(s.startTime)
+ resp := StatusResponse{
+ Status: "ok",
+ Uptime: uptime.String(),
+ }
+
+ json.NewEncoder(w).Encode(resp)
+}
+
+func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+
+ s.mu.RLock()
+ ready := s.ready
+ checks := make(map[string]Check)
+ for k, v := range s.checks {
+ checks[k] = v
+ }
+ s.mu.RUnlock()
+
+ if !ready {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ json.NewEncoder(w).Encode(StatusResponse{
+ Status: "not ready",
+ Checks: checks,
+ })
+ return
+ }
+
+ for _, check := range checks {
+ if check.Status == "fail" {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ json.NewEncoder(w).Encode(StatusResponse{
+ Status: "not ready",
+ Checks: checks,
+ })
+ return
+ }
+ }
+
+ w.WriteHeader(http.StatusOK)
+ uptime := time.Since(s.startTime)
+ json.NewEncoder(w).Encode(StatusResponse{
+ Status: "ready",
+ Uptime: uptime.String(),
+ Checks: checks,
+ })
+}
+
+func statusString(ok bool) string {
+ if ok {
+ return "ok"
+ }
+ return "fail"
+}
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index 9c1e36359..57032e566 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -108,7 +108,10 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
case "anthropic":
cfg.Providers.Anthropic = pc
case "openai":
- cfg.Providers.OpenAI = pc
+ cfg.Providers.OpenAI = config.OpenAIProviderConfig{
+ ProviderConfig: pc,
+ WebSearch: getBoolOrDefault(pMap, "web_search", true),
+ }
case "openrouter":
cfg.Providers.OpenRouter = pc
case "groq":
@@ -363,6 +366,13 @@ func getBool(data map[string]interface{}, key string) (bool, bool) {
return b, ok
}
+func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool {
+ if v, ok := getBool(data, key); ok {
+ return v
+ }
+ return defaultVal
+}
+
func getStringSlice(data map[string]interface{}, key string) []string {
v, ok := data[key]
if !ok {
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index d1bcb4545..b0bf19318 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -313,7 +313,7 @@ func (sm *SessionManager) CleanupStale(maxAge time.Duration) int {
delete(sm.sessions, key)
// Also remove the session file if it exists
if sm.storage != "" {
- sessionPath := filepath.Join(sm.storage, key+".json")
+ sessionPath := filepath.Join(sm.storage, sanitizeFilename(key)+".json")
os.Remove(sessionPath)
}
removed++
@@ -331,13 +331,26 @@ func (sm *SessionManager) CleanupStale(maxAge time.Duration) int {
return removed
}
+// sanitizeFilename converts a session key into a cross-platform safe filename.
+// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
+// volume separator on Windows, so filepath.Base would misinterpret the key.
+// We replace it with '_'. The original key is preserved inside the JSON file,
+// so loadSessions still maps back to the right in-memory key.
+func sanitizeFilename(key string) string {
+ return strings.ReplaceAll(key, ":", "_")
+}
func (sm *SessionManager) Save(key string) error {
if sm.storage == "" {
return nil
}
- // Validate key to avoid invalid filenames and path traversal.
- if key == "" || key == "." || key == ".." || key != filepath.Base(key) || strings.Contains(key, "/") || strings.Contains(key, "\\") {
+ filename := sanitizeFilename(key)
+
+ // filepath.IsLocal rejects empty names, "..", absolute paths, and
+ // OS-reserved device names (NUL, COM1 … on Windows).
+ // The extra checks reject "." and any directory separators so that
+ // the session file is always written directly inside sm.storage.
+ if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
return os.ErrInvalid
}
@@ -389,7 +402,7 @@ func (sm *SessionManager) writeSessionToDisk(key string, session *Session) error
return err
}
- sessionPath := filepath.Join(sm.storage, key+".json")
+ sessionPath := filepath.Join(sm.storage, sanitizeFilename(key)+".json")
tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp")
if err != nil {
return err
@@ -464,3 +477,19 @@ func (sm *SessionManager) loadSessions() error {
return nil
}
+
+// SetHistory updates the messages of a session.
+func (sm *SessionManager) SetHistory(key string, history []messages.Message) {
+ sm.mu.Lock()
+ defer sm.mu.Unlock()
+
+ session, ok := sm.sessions[key]
+ if ok {
+ // Create a deep copy to strictly isolate internal state
+ // from the caller's slice.
+ msgs := make([]messages.Message, len(history))
+ copy(msgs, history)
+ session.Messages = msgs
+ session.Updated = time.Now()
+ }
+}
diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go
index 81a3f8656..5338d0e4b 100644
--- a/pkg/session/manager_test.go
+++ b/pkg/session/manager_test.go
@@ -1,11 +1,81 @@
package session
import (
+ "os"
+ "path/filepath"
"testing"
+ "time"
"github.com/sipeed/picoclaw/pkg/messages"
)
+func TestSanitizeFilename(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"simple", "simple"},
+ {"telegram:123456", "telegram_123456"},
+ {"discord:987654321", "discord_987654321"},
+ {"slack:C01234", "slack_C01234"},
+ {"no-colons-here", "no-colons-here"},
+ {"multiple:colons:here", "multiple_colons_here"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := sanitizeFilename(tt.input)
+ if got != tt.expected {
+ t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestSave_WithColonInKey(t *testing.T) {
+ tmpDir := t.TempDir()
+ sm := NewSessionManager(tmpDir)
+
+ // Create a session with a key containing colon (typical channel session key).
+ key := "telegram:123456"
+ sm.GetOrCreate(key)
+ sm.AddMessage(key, "user", "hello")
+
+ // Save should succeed even though the key contains ':'
+ if err := sm.Save(key); err != nil {
+ t.Fatalf("Save(%q) failed: %v", key, err)
+ }
+
+ // The file on disk should use sanitized name.
+ expectedFile := filepath.Join(tmpDir, "telegram_123456.json")
+ if _, err := os.Stat(expectedFile); os.IsNotExist(err) {
+ t.Fatalf("expected session file %s to exist", expectedFile)
+ }
+
+ // Load into a fresh manager and verify the session round-trips.
+ sm2 := NewSessionManager(tmpDir)
+ history := sm2.GetHistory(key)
+ if len(history) != 1 {
+ t.Fatalf("expected 1 message after reload, got %d", len(history))
+ }
+ if history[0].Content != "hello" {
+ t.Errorf("expected message content %q, got %q", "hello", history[0].Content)
+ }
+}
+
+func TestSave_RejectsPathTraversal(t *testing.T) {
+ tmpDir := t.TempDir()
+ sm := NewSessionManager(tmpDir)
+
+ badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"}
+ for _, key := range badKeys {
+ sm.GetOrCreate(key)
+ if err := sm.Save(key); err == nil {
+ t.Errorf("Save(%q) should have failed but didn't", key)
+ }
+ }
+}
+
func TestTruncateHistory_ToolCallAware(t *testing.T) {
sm := NewSessionManager("") // in-memory only
@@ -100,10 +170,10 @@ func TestCleanupStale(t *testing.T) {
// Make "stale" session appear old by directly modifying its Updated time
sm.mu.Lock()
- sm.sessions["stale"].Updated = sm.sessions["stale"].Updated.Add(-8 * 24 * 3600e9) // 8 days ago
+ sm.sessions["stale"].Updated = sm.sessions["stale"].Updated.Add(-8 * 24 * time.Hour) // 8 days ago
sm.mu.Unlock()
- removed := sm.CleanupStale(7 * 24 * 3600e9) // 7 day TTL
+ removed := sm.CleanupStale(7 * 24 * time.Hour) // 7 day TTL
if removed != 1 {
t.Errorf("expected 1 stale session removed, got %d", removed)
diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go
index 1f952c1f5..0c63ae067 100644
--- a/pkg/skills/loader.go
+++ b/pkg/skills/loader.go
@@ -2,13 +2,22 @@ package skills
import (
"encoding/json"
+ "errors"
"fmt"
+ "log/slog"
"os"
"path/filepath"
"regexp"
"strings"
)
+var namePattern = regexp.MustCompile(`^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$`)
+
+const (
+ MaxNameLength = 64
+ MaxDescriptionLength = 1024
+)
+
type SkillMetadata struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -21,6 +30,27 @@ type SkillInfo struct {
Description string `json:"description"`
}
+func (info SkillInfo) validate() error {
+ var errs error
+ if info.Name == "" {
+ errs = errors.Join(errs, errors.New("name is required"))
+ } else {
+ if len(info.Name) > MaxNameLength {
+ errs = errors.Join(errs, fmt.Errorf("name exceeds %d characters", MaxNameLength))
+ }
+ if !namePattern.MatchString(info.Name) {
+ errs = errors.Join(errs, errors.New("name must be alphanumeric with hyphens"))
+ }
+ }
+
+ if info.Description == "" {
+ errs = errors.Join(errs, errors.New("description is required"))
+ } else if len(info.Description) > MaxDescriptionLength {
+ errs = errors.Join(errs, fmt.Errorf("description exceeds %d character", MaxDescriptionLength))
+ }
+ return errs
+}
+
type SkillsLoader struct {
workspace string
workspaceSkills string // workspace skills (项目级别)
@@ -54,6 +84,11 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
+ info.Name = metadata.Name
+ }
+ if err := info.validate(); err != nil {
+ slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
+ continue
}
skills = append(skills, info)
}
@@ -89,6 +124,11 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
+ info.Name = metadata.Name
+ }
+ if err := info.validate(); err != nil {
+ slog.Warn("invalid skill from global", "name", info.Name, "error", err)
+ continue
}
skills = append(skills, info)
}
@@ -123,6 +163,11 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
+ info.Name = metadata.Name
+ }
+ if err := info.validate(); err != nil {
+ slog.Warn("invalid skill from builtin", "name", info.Name, "error", err)
+ continue
}
skills = append(skills, info)
}
diff --git a/pkg/skills/loader_test.go b/pkg/skills/loader_test.go
new file mode 100644
index 000000000..e0e7109cf
--- /dev/null
+++ b/pkg/skills/loader_test.go
@@ -0,0 +1,77 @@
+package skills
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSkillsInfoValidate(t *testing.T) {
+ testcases := []struct {
+ name string
+ skillName string
+ description string
+ wantErr bool
+ errContains []string
+ }{
+ {
+ name: "valid-skill",
+ skillName: "valid-skill",
+ description: "a valid skill description",
+ wantErr: false,
+ },
+ {
+ name: "empty-name",
+ skillName: "",
+ description: "description without name",
+ wantErr: true,
+ errContains: []string{"name is required"},
+ },
+ {
+ name: "empty-description",
+ skillName: "skill-without-description",
+ description: "",
+ wantErr: true,
+ errContains: []string{"description is required"},
+ },
+ {
+ name: "empty-both",
+ skillName: "",
+ description: "",
+ wantErr: true,
+ errContains: []string{"name is required", "description is required"},
+ },
+ {
+ name: "name-with-spaces",
+ skillName: "skill with spaces",
+ description: "invalid name with spaces",
+ wantErr: true,
+ errContains: []string{"name must be alphanumeric with hyphens"},
+ },
+ {
+ name: "name-with-underscore",
+ skillName: "skill_underscore",
+ description: "invalid name with underscore",
+ wantErr: true,
+ errContains: []string{"name must be alphanumeric with hyphens"},
+ },
+ }
+
+ for _, tc := range testcases {
+ t.Run(tc.name, func(t *testing.T) {
+ info := SkillInfo{
+ Name: tc.skillName,
+ Description: tc.description,
+ }
+ err := info.validate()
+ if tc.wantErr {
+ assert.Error(t, err)
+ for _, msg := range tc.errContains {
+ assert.ErrorContains(t, err, msg)
+ }
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go
index 0ef745e2b..21bee42ef 100644
--- a/pkg/tools/cron.go
+++ b/pkg/tools/cron.go
@@ -28,12 +28,15 @@ type CronTool struct {
}
// NewCronTool creates a new CronTool
-func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string) *CronTool {
+// execTimeout: 0 means no timeout, >0 sets the timeout duration
+func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *CronTool {
+ execTool := NewExecTool(workspace, restrict)
+ execTool.SetTimeout(execTimeout) // 0 means no timeout
return &CronTool{
cronService: cronService,
executor: executor,
msgBus: msgBus,
- execTool: NewExecTool(workspace, false),
+ execTool: execTool,
}
}
diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go
index 237687734..09063ea0a 100644
--- a/pkg/tools/filesystem.go
+++ b/pkg/tools/filesystem.go
@@ -29,13 +29,54 @@ func validatePath(path, workspace string, restrict bool) (string, error) {
}
}
- if restrict && !strings.HasPrefix(absPath, absWorkspace) {
- return "", fmt.Errorf("access denied: path is outside the workspace")
+ if restrict {
+ if !isWithinWorkspace(absPath, absWorkspace) {
+ return "", fmt.Errorf("access denied: path is outside the workspace")
+ }
+
+ workspaceReal := absWorkspace
+ if resolved, err := filepath.EvalSymlinks(absWorkspace); err == nil {
+ workspaceReal = resolved
+ }
+
+ if resolved, err := filepath.EvalSymlinks(absPath); err == nil {
+ if !isWithinWorkspace(resolved, workspaceReal) {
+ return "", fmt.Errorf("access denied: symlink resolves outside workspace")
+ }
+ } else if os.IsNotExist(err) {
+ if parentResolved, err := resolveExistingAncestor(filepath.Dir(absPath)); err == nil {
+ if !isWithinWorkspace(parentResolved, workspaceReal) {
+ return "", fmt.Errorf("access denied: symlink resolves outside workspace")
+ }
+ } else if !os.IsNotExist(err) {
+ return "", fmt.Errorf("failed to resolve path: %w", err)
+ }
+ } else {
+ return "", fmt.Errorf("failed to resolve path: %w", err)
+ }
}
return absPath, nil
}
+func resolveExistingAncestor(path string) (string, error) {
+ for current := filepath.Clean(path); ; current = filepath.Dir(current) {
+ if resolved, err := filepath.EvalSymlinks(current); err == nil {
+ return resolved, nil
+ } else if !os.IsNotExist(err) {
+ return "", err
+ }
+ if filepath.Dir(current) == current {
+ return "", os.ErrNotExist
+ }
+ }
+}
+
+func isWithinWorkspace(candidate, workspace string) bool {
+ rel, err := filepath.Rel(filepath.Clean(workspace), filepath.Clean(candidate))
+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
+}
+
type ReadFileTool struct {
workspace string
restrict bool
diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go
index 2707f29b5..958036419 100644
--- a/pkg/tools/filesystem_test.go
+++ b/pkg/tools/filesystem_test.go
@@ -247,3 +247,35 @@ func TestFilesystemTool_ListDir_DefaultPath(t *testing.T) {
t.Errorf("Expected success with default path '.', got IsError=true: %s", result.ForLLM)
}
}
+
+// Block paths that look inside workspace but point outside via symlink.
+func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) {
+
+ root := t.TempDir()
+ workspace := filepath.Join(root, "workspace")
+ if err := os.MkdirAll(workspace, 0755); err != nil {
+ t.Fatalf("failed to create workspace: %v", err)
+ }
+
+ secret := filepath.Join(root, "secret.txt")
+ if err := os.WriteFile(secret, []byte("top secret"), 0644); err != nil {
+ t.Fatalf("failed to write secret file: %v", err)
+ }
+
+ link := filepath.Join(workspace, "leak.txt")
+ if err := os.Symlink(secret, link); err != nil {
+ t.Skipf("symlink not supported in this environment: %v", err)
+ }
+
+ tool := NewReadFileTool(workspace, true)
+ result := tool.Execute(context.Background(), map[string]interface{}{
+ "path": link,
+ })
+
+ if !result.IsError {
+ t.Fatalf("expected symlink escape to be blocked")
+ }
+ if !strings.Contains(result.ForLLM, "symlink resolves outside workspace") {
+ t.Fatalf("expected symlink escape error, got: %s", result.ForLLM)
+ }
+}
diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go
index a4851e5a1..5d07229cf 100644
--- a/pkg/tools/shell.go
+++ b/pkg/tools/shell.go
@@ -168,7 +168,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To
return ErrorResult(guardError)
}
- cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
+ // timeout == 0 means no timeout
+ var cmdCtx context.Context
+ var cancel context.CancelFunc
+ if t.timeout > 0 {
+ cmdCtx, cancel = context.WithTimeout(ctx, t.timeout)
+ } else {
+ cmdCtx, cancel = context.WithCancel(ctx)
+ }
defer cancel()
var cmd *exec.Cmd
diff --git a/pkg/tools/web.go b/pkg/tools/web.go
index ccd995842..6a6d40ecf 100644
--- a/pkg/tools/web.go
+++ b/pkg/tools/web.go
@@ -176,6 +176,71 @@ func stripTags(content string) string {
return re.ReplaceAllString(content, "")
}
+type PerplexitySearchProvider struct {
+ apiKey string
+}
+
+func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
+ searchURL := "https://api.perplexity.ai/chat/completions"
+
+ payload := map[string]interface{}{
+ "model": "sonar",
+ "messages": []map[string]string{
+ {"role": "system", "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary."},
+ {"role": "user", "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count)},
+ },
+ "max_tokens": 1000,
+ }
+
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return "", fmt.Errorf("failed to marshal request: %w", err)
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes)))
+ if err != nil {
+ return "", fmt.Errorf("failed to create request: %w", err)
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+p.apiKey)
+ req.Header.Set("User-Agent", userAgent)
+
+ client := &http.Client{Timeout: 30 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read response: %w", err)
+ }
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("Perplexity API error: %s", string(body))
+ }
+
+ var searchResp struct {
+ Choices []struct {
+ Message struct {
+ Content string `json:"content"`
+ } `json:"message"`
+ } `json:"choices"`
+ }
+
+ if err := json.Unmarshal(body, &searchResp); err != nil {
+ return "", fmt.Errorf("failed to parse response: %w", err)
+ }
+
+ if len(searchResp.Choices) == 0 {
+ return fmt.Sprintf("No results for: %s", query), nil
+ }
+
+ return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
+}
+
type WebSearchTool struct {
provider SearchProvider
maxResults int
@@ -187,14 +252,22 @@ type WebSearchToolOptions struct {
BraveEnabled bool
DuckDuckGoMaxResults int
DuckDuckGoEnabled bool
+ PerplexityAPIKey string
+ PerplexityMaxResults int
+ PerplexityEnabled bool
}
func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool {
var provider SearchProvider
maxResults := 5
- // Priority: Brave > DuckDuckGo
- if opts.BraveEnabled && opts.BraveAPIKey != "" {
+ // Priority: Perplexity > Brave > DuckDuckGo
+ if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
+ provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey}
+ if opts.PerplexityMaxResults > 0 {
+ maxResults = opts.PerplexityMaxResults
+ }
+ } else if opts.BraveEnabled && opts.BraveAPIKey != "" {
provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey}
if opts.BraveMaxResults > 0 {
maxResults = opts.BraveMaxResults
diff --git a/pkg/tools/web_test.go b/pkg/tools/web_test.go
index 988eada16..a526ea34a 100644
--- a/pkg/tools/web_test.go
+++ b/pkg/tools/web_test.go
@@ -173,19 +173,23 @@ func TestWebTool_WebFetch_Truncation(t *testing.T) {
}
}
-// TestWebTool_WebSearch_NoApiKey verifies that nil is returned when no provider is configured
+// TestWebTool_WebSearch_NoApiKey verifies that no tool is created when API key is missing
func TestWebTool_WebSearch_NoApiKey(t *testing.T) {
- tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "", BraveMaxResults: 5})
-
- // Should return nil when no provider is enabled
+ tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: ""})
if tool != nil {
- t.Errorf("Expected nil when no search provider is configured")
+ t.Errorf("Expected nil tool when Brave API key is empty")
+ }
+
+ // Also nil when nothing is enabled
+ tool = NewWebSearchTool(WebSearchToolOptions{})
+ if tool != nil {
+ t.Errorf("Expected nil tool when no provider is enabled")
}
}
// TestWebTool_WebSearch_MissingQuery verifies error handling for missing query
func TestWebTool_WebSearch_MissingQuery(t *testing.T) {
- tool := NewWebSearchTool(WebSearchToolOptions{BraveAPIKey: "test-key", BraveMaxResults: 5, BraveEnabled: true})
+ tool := NewWebSearchTool(WebSearchToolOptions{BraveEnabled: true, BraveAPIKey: "test-key", BraveMaxResults: 5})
ctx := context.Background()
args := map[string]interface{}{}
diff --git a/pkg/utils/media.go b/pkg/utils/media.go
index 6345da8fc..2b184f2ec 100644
--- a/pkg/utils/media.go
+++ b/pkg/utils/media.go
@@ -73,9 +73,8 @@ func DownloadFile(url, filename string, opts DownloadOptions) string {
}
// Generate unique filename with UUID prefix to prevent conflicts
- ext := filepath.Ext(filename)
safeName := SanitizeFilename(filename)
- localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName+ext)
+ localPath := filepath.Join(mediaDir, uuid.New().String()[:8]+"_"+safeName)
// Create HTTP request
req, err := http.NewRequest("GET", url, nil)
diff --git a/workspace/AGENT.md b/workspace/AGENT.md
new file mode 100644
index 000000000..5f5fa6480
--- /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 000000000..dabb0e14b
--- /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 000000000..0be8834f5
--- /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 000000000..91398a019
--- /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 000000000..265271db9
--- /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/skills/hardware/SKILL.md b/workspace/skills/hardware/SKILL.md
similarity index 100%
rename from skills/hardware/SKILL.md
rename to workspace/skills/hardware/SKILL.md
diff --git a/skills/hardware/references/board-pinout.md b/workspace/skills/hardware/references/board-pinout.md
similarity index 100%
rename from skills/hardware/references/board-pinout.md
rename to workspace/skills/hardware/references/board-pinout.md
diff --git a/skills/hardware/references/common-devices.md b/workspace/skills/hardware/references/common-devices.md
similarity index 100%
rename from skills/hardware/references/common-devices.md
rename to workspace/skills/hardware/references/common-devices.md
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