Merge main into refactor/agent-loop-decomposition

Resolve conflicts by accepting main's versions for loop.go, factory.go,
types.go, and README.md. Remove obsolete branch files (constants.go,
context_compressor.go, context_compressor_test.go, tool_executor.go) that
were superseded by main's ContextManager system. Retain contextWindowPatterns
and IsContextWindowError additions to error_classifier.go.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sai Balusu 2026-04-05 23:18:52 -04:00
commit 90340ecf4b
786 changed files with 143650 additions and 23447 deletions

View file

@ -5,6 +5,7 @@
# ANTHROPIC_API_KEY=sk-ant-xxx
# OPENAI_API_KEY=sk-xxx
# GEMINI_API_KEY=xxx
# MODELSCOPE_API_KEY=xxx
# CLAUDE_CODE_OAUTH=xxx
# ── Chat Channel ──────────────────────────
# TELEGRAM_BOT_TOKEN=123456:ABC...

27
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,27 @@
version: 2
updates:
# Go dependencies (entire repo)
- package-ecosystem: "gomod"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "go"
# Frontend dependencies
- package-ecosystem: "npm"
directory: "/web/frontend"
schedule:
interval: "weekly"
labels:
- "dependencies"
- "frontend"
# GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

62
.github/workflows/create_dmg.yml vendored Normal file
View file

@ -0,0 +1,62 @@
name: Create macOS DMG
on:
workflow_dispatch:
jobs:
build:
name: Build ${{ matrix.arch }}
runs-on: macos-latest
strategy:
matrix:
# This creates two parallel jobs
arch: [arm64, amd64]
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
ref: main
# 1. 安装指定版本的 Go (可选,但推荐)
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
# 2. 安装 pnpm
- name: Install pnpm
run: brew install pnpm
# 3. 运行你的 Makefile 编译二进制文件
- name: Build with Make
run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }}
# 4. 签名
- name: Ad-hoc Sign
run: codesign --force --deep --sign - "build/PicoClaw Launcher.app"
# 5. 安装打包工具
- name: Install create-dmg
run: brew install create-dmg
# 6. 执行打包命令
- name: Create DMG
run: |
mkdir -p dist
create-dmg \
--volname "PicoClaw Installer" \
--window-pos 200 120 \
--window-size 800 400 \
--icon-size 100 \
--icon "PicoClaw Launcher.app" 200 190 \
--hide-extension "PicoClaw Launcher.app" \
--app-drop-link 600 185 \
"dist/picoclaw-${{ matrix.arch }}.dmg" \
"build/PicoClaw Launcher.app"
# 7. 上传文件到 GitHub Artifacts (供你下载)
- name: Upload DMG
uses: actions/upload-artifact@v7
with:
name: macos-dmg-${{ matrix.arch }}
path: dist/*.dmg

View file

@ -31,11 +31,11 @@ jobs:
# ── Docker Buildx ─────────────────────────
- name: 🔧 Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# ── Login to GHCR ─────────────────────────
- name: 🔑 Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
@ -43,7 +43,7 @@ jobs:
# ── Login to Docker Hub ────────────────────
- name: 🔑 Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.DOCKERHUB_REGISTRY }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
@ -62,7 +62,7 @@ jobs:
# ── Build & Push ──────────────────────────
- name: 🚀 Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
push: true

View file

@ -9,64 +9,37 @@ permissions:
contents: read
jobs:
create-tag:
name: Create Git Tag
nightly:
name: Nightly Build
runs-on: ubuntu-latest
permissions:
contents: write
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
changelog: ${{ steps.version.outputs.changelog }}
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Generate and push tag
- name: Compute version
id: version
run: |
DATE=$(date -u +%Y%m%d)
SHA=$(git rev-parse --short=8 HEAD)
BASE_VERSION=$(git describe --tags --match "v*" --exclude "*nightly*" --abbrev=0 2>/dev/null || true)
if [ -z "$BASE_VERSION" ] || [ "$BASE_VERSION" = "v0.0.0" ]; then
TAG="v0.0.0-nightly.${DATE}.${SHA}"
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
else
TAG="${BASE_VERSION}-nightly.${DATE}.${SHA}"
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
fi
VERSION=$TAG
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then
echo "Tag $TAG already exists, reusing existing tag"
else
git tag -a "$TAG" -m "Nightly build $VERSION"
fi
git push origin "$TAG"
COMPARE_URL="https://github.com/${{ github.repository }}/commits/${TAG}"
COMPARE_URL="https://github.com/${{ github.repository }}/commits/main"
if [ -n "$BASE_VERSION" ] && [ "$BASE_VERSION" != "v0.0.0" ]; then
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...${TAG}"
COMPARE_URL="https://github.com/${{ github.repository }}/compare/${BASE_VERSION}...main"
fi
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
release:
name: GoReleaser Release
needs: create-tag
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout tag
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ needs.create-tag.outputs.tag }}
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
- name: Setup Go from go.mod
id: setup-go
@ -75,7 +48,7 @@ jobs:
go-version-file: go.mod
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
@ -83,20 +56,30 @@ jobs:
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v4
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Create local tag for GoReleaser
run: git tag "${{ steps.version.outputs.version }}"
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: ~> v2
@ -106,6 +89,7 @@ jobs:
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
NIGHTLY_BUILD: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
@ -113,26 +97,14 @@ jobs:
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
update-rolling:
name: Update Rolling Nightly
needs: [create-tag, release]
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Update nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.create-tag.outputs.tag }}
TITLE: ${{ needs.create-tag.outputs.version }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANGELOG='${{ needs.create-tag.outputs.changelog }}'
CHANGELOG='${{ steps.version.outputs.changelog }}'
NOTES=$(cat <<EOF
Nightly build for **${TITLE}**
Nightly build for **${VERSION}**
This is an automated build and may be unstable. Use with caution.
@ -140,65 +112,27 @@ jobs:
EOF
)
# Download assets from the newly created release if it exists,
# otherwise fall back to using locally built dist/ artifacts.
mkdir -p build
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Downloading assets from GitHub release for $TAG..."
gh release download "$TAG" --dir build
else
echo "GitHub release for $TAG not found; falling back to local dist/ artifacts..."
if [ -d "dist" ]; then
cp -R dist/* build/
else
echo "Error: no GitHub release for $TAG and no local dist/ directory found." >&2
exit 1
fi
fi
# Delete existing nightly release and tag
gh release delete nightly --cleanup-tag -y 2>/dev/null || true
# Delete existing nightly release and tag to avoid conflicts
echo "Deleting existing nightly release and tag..."
gh release delete nightly --cleanup-tag -y || true
git push origin :refs/tags/nightly || true
# Force-update nightly tag to current HEAD
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -fa nightly -m "Nightly build ${VERSION}"
git push origin nightly
# Collect release artifacts from goreleaser dist/
ASSETS=()
for f in dist/*.tar.gz dist/*.zip dist/*.deb dist/*.rpm dist/checksums.txt; do
[ -f "$f" ] && ASSETS+=("$f")
done
# Create nightly release (prerelease, NOT latest)
gh release create nightly \
--title "Nightly Build" \
--notes "$NOTES" \
--target "${{ github.sha }}" \
--prerelease \
build/*
--latest=false \
"${ASSETS[@]}"
echo "Cleaning up old nightly releases (keeping only the most recent)..."
gh release list --limit 100 --json tagName -q '.[].tagName | select(contains("-nightly."))' | tail -n +2 | while read -r old_tag; do
if [ -n "$old_tag" ] && [ "$old_tag" != "$TAG" ]; then
echo "Deleting old nightly release: $old_tag"
gh release delete "$old_tag" --cleanup-tag -y || true
fi
done
echo "Cleaning up old 'vX.X.X-nightly...' Docker images on GHCR..."
OWNER="${{ github.repository_owner }}"
PACKAGE_NAME="${{ github.event.repository.name }}"
# Check if owner is an organization or user
ORG_TEST=$(gh api -H "Accept: application/vnd.github+json" /orgs/$OWNER 2>/dev/null || true)
if echo "$ORG_TEST" | grep -q '"login"'; then
ACCOUNT_TYPE="orgs"
else
ACCOUNT_TYPE="users"
fi
PACKAGE_URL="/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions"
OLD_NIGHTLY_VERSIONS=$(gh api --paginate -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"$PACKAGE_URL" \
--jq ". | map(select(any(.metadata.container.tags[]; contains(\"-nightly.\") and (. != \"nightly\") and (. != \"$TAG\")))) | .[].id" 2>/dev/null || true)
for version_id in $OLD_NIGHTLY_VERSIONS; do
if [ -n "$version_id" ]; then
echo "Deleting Docker image version ID: $version_id"
gh api -X DELETE -H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"/${ACCOUNT_TYPE}/${OWNER}/packages/container/${PACKAGE_NAME}/versions/$version_id" || true
fi
done

View file

@ -23,10 +23,13 @@ jobs:
uses: golangci/golangci-lint-action@v9
with:
version: v2.10.1
args: --build-tags=goolm,stdjson
vuln_check:
name: Security Check
runs-on: ubuntu-latest
env:
GOFLAGS: -tags=goolm,stdjson
steps:
- name: Checkout
uses: actions/checkout@v6
@ -34,7 +37,7 @@ jobs:
persist-credentials: false
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: go.mod
@ -59,4 +62,4 @@ jobs:
run: go generate ./...
- name: Run go test
run: go test ./...
run: go test -tags goolm,stdjson ./...

View file

@ -66,7 +66,7 @@ jobs:
go-version-file: go.mod
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
@ -74,27 +74,27 @@ jobs:
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
uses: goreleaser/goreleaser-action@v7
with:
distribution: goreleaser
version: ~> v2

13
.gitignore vendored
View file

@ -25,6 +25,9 @@ build/
# Secrets & Config (keep templates, ignore actual secrets)
.env
config/config.json
.security.yml
onboard
# Test
coverage.txt
@ -40,6 +43,7 @@ tasks/
# Plans
docs/plans/
docs/superpowers/
# Editors
.vscode/
@ -52,7 +56,16 @@ dist/
# Windows Application Icon/Resource
*.syso
# Test telegram integration
cmd/telegram/
# Keep embedded backend dist directory placeholder in VCS
!web/backend/dist/
web/backend/dist/*
!web/backend/dist/.gitkeep
.claude/
docker/data
.omc/

View file

@ -12,6 +12,7 @@ linters:
- exhaustruct
- funcorder
- gochecknoglobals
- gosmopolitan # Project legitimately uses CJK text in tests (FTS5, token counting)
- godot
- intrange
- ireturn
@ -61,6 +62,9 @@ linters:
- usestdlibvars
- usetesting
settings:
gomoddirectives:
replace-allow-list:
- github.com/bwmarrin/discordgo
errcheck:
check-type-assertions: true
check-blank: true

View file

@ -2,6 +2,11 @@
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
version: 2
git:
ignore_tags:
- nightly
- ".*-nightly.*"
before:
hooks:
- go mod tidy
@ -15,18 +20,20 @@ builds:
env:
- CGO_ENABLED=0
tags:
- goolm
- stdjson
ldflags:
- -s -w
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.version={{ .Version }}
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.gitCommit={{ .ShortCommit }}
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.buildTime={{ .Date }}
- -X github.com/sipeed/picoclaw/cmd/picoclaw/internal.goVersion={{ .Env.GOVERSION }}
- -X github.com/sipeed/picoclaw/pkg/config.Version={{ .Version }}
- -X github.com/sipeed/picoclaw/pkg/config.GitCommit={{ .ShortCommit }}
- -X github.com/sipeed/picoclaw/pkg/config.BuildTime={{ .Date }}
- -X github.com/sipeed/picoclaw/pkg/config.GoVersion={{ .Env.GOVERSION }}
goos:
- linux
- windows
- darwin
- freebsd
- netbsd
goarch:
- amd64
- arm64
@ -44,12 +51,19 @@ builds:
ignore:
- goos: windows
goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
- id: picoclaw-launcher
binary: picoclaw-launcher
env:
- CGO_ENABLED=0
tags:
- goolm
- stdjson
ldflags:
- -s -w
@ -58,6 +72,7 @@ builds:
- windows
- darwin
- freebsd
- netbsd
goarch:
- amd64
- arm64
@ -75,12 +90,19 @@ builds:
ignore:
- goos: windows
goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
- id: picoclaw-launcher-tui
binary: picoclaw-launcher-tui
env:
- CGO_ENABLED=0
tags:
- goolm
- stdjson
ldflags:
- -s -w
@ -89,6 +111,7 @@ builds:
- windows
- darwin
- freebsd
- netbsd
goarch:
- amd64
- arm64
@ -106,6 +129,12 @@ builds:
ignore:
- goos: windows
goarch: arm
- goos: netbsd
goarch: s390x
- goos: netbsd
goarch: mips64
- goos: netbsd
goarch: arm
dockers_v2:
- id: picoclaw
@ -116,15 +145,32 @@ dockers_v2:
- picoclaw
images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- '{{ if not (isEnvSet "NIGHTLY_BUILD") }}docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}{{ end }}'
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
tags:
- "{{ .Tag }}"
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}{{ .Tag }}{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly{{ else }}latest{{ end }}'
platforms:
- linux/amd64
- linux/arm64
- linux/riscv64
- id: picoclaw-launcher
dockerfile: docker/Dockerfile.goreleaser.launcher
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
tags:
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}{{ .Tag }}-launcher{{ end }}'
- '{{ if isEnvSet "NIGHTLY_BUILD" }}nightly-launcher{{ else }}launcher{{ end }}'
platforms:
- linux/amd64
- linux/arm64
- linux/riscv64
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
@ -198,6 +244,7 @@ changelog:
# lzma: true
release:
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
footer: >-
---

149
Makefile
View file

@ -5,18 +5,26 @@ BINARY_NAME=picoclaw
BUILD_DIR=build
CMD_DIR=cmd/$(BINARY_NAME)
MAIN_GO=$(CMD_DIR)/main.go
EXT=
# Version
VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev")
BUILD_TIME=$(shell date +%FT%T%z)
GO_VERSION=$(shell $(GO) version | awk '{print $$3}')
INTERNAL=github.com/sipeed/picoclaw/cmd/picoclaw/internal
LDFLAGS=-ldflags "-X $(INTERNAL).version=$(VERSION) -X $(INTERNAL).gitCommit=$(GIT_COMMIT) -X $(INTERNAL).buildTime=$(BUILD_TIME) -X $(INTERNAL).goVersion=$(GO_VERSION) -s -w"
CONFIG_PKG=github.com/sipeed/picoclaw/pkg/config
LDFLAGS=-X $(CONFIG_PKG).Version=$(VERSION) -X $(CONFIG_PKG).GitCommit=$(GIT_COMMIT) -X $(CONFIG_PKG).BuildTime=$(BUILD_TIME) -X $(CONFIG_PKG).GoVersion=$(GO_VERSION) -s -w
# Go variables
GO?=CGO_ENABLED=0 go
GOFLAGS?=-v -tags stdjson
WEB_GO?=$(GO)
GO_BUILD_TAGS?=goolm,stdjson
GOFLAGS?=-v -tags $(GO_BUILD_TAGS)
comma:=,
empty:=
space:=$(empty) $(empty)
GO_BUILD_TAGS_NO_GOOLM:=$(subst $(space),$(comma),$(strip $(filter-out goolm,$(subst $(comma),$(space),$(GO_BUILD_TAGS)))))
GOFLAGS_NO_GOOLM?=-v -tags $(GO_BUILD_TAGS_NO_GOOLM)
# Patch MIPS LE ELF e_flags (offset 36) for NaN2008-only kernels (e.g. Ingenic X2600).
#
@ -40,6 +48,13 @@ define PATCH_MIPS_FLAGS
fi
endef
# Patch creack/pty for loong64 support (upstream doesn't have ztypes_loong64.go)
PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \
if [ -d "$$pty_dir" ] && [ ! -f "$$pty_dir/ztypes_loong64.go" ]; then \
chmod +w "$$pty_dir" 2>/dev/null || true; \
printf '//go:build linux && loong64\npackage pty\ntype (_C_int int32; _C_uint uint32)\n' > "$$pty_dir/ztypes_loong64.go"; \
fi
# Golangci-lint
GOLANGCI_LINT?=golangci-lint
@ -55,9 +70,11 @@ WORKSPACE_DIR?=$(PICOCLAW_HOME)/workspace
WORKSPACE_SKILLS_DIR=$(WORKSPACE_DIR)/skills
BUILTIN_SKILLS_DIR=$(CURDIR)/skills
LNCMD=ln -sf
# OS detection
UNAME_S:=$(shell uname -s)
UNAME_M:=$(shell uname -m)
UNAME_S?=$(shell uname -s)
UNAME_M?=$(shell uname -m)
# Platform-specific settings
ifeq ($(UNAME_S),Linux)
@ -79,16 +96,30 @@ ifeq ($(UNAME_S),Linux)
endif
else ifeq ($(UNAME_S),Darwin)
PLATFORM=darwin
WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go
ifeq ($(UNAME_M),x86_64)
ARCH=amd64
ARCH?=amd64
else ifeq ($(UNAME_M),arm64)
ARCH=arm64
ARCH?=arm64
else
ARCH=$(UNAME_M)
ARCH?=$(UNAME_M)
endif
else
PLATFORM=$(UNAME_S)
ARCH=$(UNAME_M)
ifeq ($(UNAME_M),x86_64)
ARCH?=amd64
else
ARCH?=$(UNAME_M)
endif
# Detect Windows (Git Bash / MSYS2)
IS_WINDOWS:=$(if $(findstring MINGW,$(UNAME_S)),yes,$(if $(findstring MSYS,$(UNAME_S)),yes,$(if $(findstring CYGWIN,$(UNAME_S)),yes,no)))
ifeq ($(IS_WINDOWS),yes)
EXT=.exe
LNCMD=cp
else ifeq ($(UNAME_S),windows) # failsafe for force windows build in other OS using UNAME_S=windows
EXT=.exe
endif
endif
BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
@ -105,39 +136,50 @@ generate:
## build: Build the picoclaw binary for current platform
build: generate
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
@echo "Building $(BINARY_NAME)$(EXT) for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_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)
@GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH)$(EXT) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)$(EXT)"
@$(LNCMD) $(BINARY_NAME)-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/$(BINARY_NAME)$(EXT)
## build-launcher: Build the picoclaw-launcher (web console) binary
build-launcher:
@echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@if [ ! -f web/backend/dist/index.html ]; then \
echo "Building frontend..."; \
cd web/frontend && pnpm install && pnpm build:backend; \
fi
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH) ./web/backend
@ln -sf picoclaw-launcher-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher"
@GOARCH=${ARCH} $(MAKE) -C web build \
OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT)" \
WEB_GO='$(WEB_GO)' \
GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \
LDFLAGS='$(LDFLAGS)'
@$(LNCMD) picoclaw-launcher-$(PLATFORM)-$(ARCH)$(EXT) $(BUILD_DIR)/picoclaw-launcher$(EXT)
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher$(EXT)"
build-launcher-frontend:
@$(MAKE) -C web build-frontend
## build-launcher-tui: Build the picoclaw-launcher TUI binary
build-launcher-tui:
@echo "Building picoclaw-launcher-tui for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
@$(GO) build $(GOFLAGS) -o $(BUILD_DIR)/picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) ./cmd/picoclaw-launcher-tui
@ln -sf picoclaw-launcher-tui-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/picoclaw-launcher-tui
@echo "Build complete: $(BUILD_DIR)/picoclaw-launcher-tui"
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
build-whatsapp-native: generate
## @echo "Building $(BINARY_NAME) with WhatsApp native for $(PLATFORM)/$(ARCH)..."
@echo "Building for multiple platforms..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=loong64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags $(GO_BUILD_TAGS_NO_GOOLM),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
## @$(GO) build $(GOFLAGS) -tags whatsapp_native $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build -tags $(GO_BUILD_TAGS),whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR)
@echo "Build complete"
## @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
@ -145,21 +187,21 @@ build-whatsapp-native: generate
build-linux-arm: generate
@echo "Building for linux/arm (GOARM=7)..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm"
## build-linux-arm64: Build for Linux ARM64 (e.g. Raspberry Pi Zero 2 W 64-bit)
build-linux-arm64: generate
@echo "Building for linux/arm64..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64"
## build-linux-mipsle: Build for Linux MIPS32 LE
build-linux-mipsle: generate
@echo "Building for linux/mipsle (softfloat)..."
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
@echo "Build complete: $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle"
@ -171,16 +213,19 @@ build-pi-zero: build-linux-arm build-linux-arm64
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=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(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=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
@$(PTY_PATCH_LOONG64)
GOOS=linux GOARCH=loong64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR)
GOOS=linux GOARCH=riscv64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR)
GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build $(GOFLAGS_NO_GOOLM) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR)
$(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(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)
GOOS=linux GOARCH=arm GOARM=7 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-armv7 ./$(CMD_DIR)
GOOS=darwin GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR)
GOOS=windows GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR)
GOOS=netbsd GOARCH=amd64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-amd64 ./$(CMD_DIR)
GOOS=netbsd GOARCH=arm64 $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-netbsd-arm64 ./$(CMD_DIR)
@echo "All builds complete"
## install: Install picoclaw to system and copy builtin skills
@ -217,11 +262,14 @@ clean:
## vet: Run go vet for static analysis
vet: generate
@$(GO) vet ./...
@packages="$$($(GO) list $(GOFLAGS) ./...)" && \
$(GO) vet $(GOFLAGS) $$(printf '%s\n' "$$packages" | grep -v '^github.com/sipeed/picoclaw/web/')
@cd web/backend && $(WEB_GO) vet ./...
## test: Test Go code
test: generate
@$(GO) test ./...
@$(GO) test $(GOFLAGS) $$($(GO) list $(GOFLAGS) ./... | grep -v github.com/sipeed/picoclaw/web/)
@cd web && make test
## fmt: Format Go code
fmt:
@ -229,11 +277,11 @@ fmt:
## lint: Run linters
lint:
@$(GOLANGCI_LINT) run
@$(GOLANGCI_LINT) run --build-tags $(GO_BUILD_TAGS)
## fix: Fix linting issues
fix:
@$(GOLANGCI_LINT) run --fix
@$(GOLANGCI_LINT) run --fix --build-tags $(GO_BUILD_TAGS)
## deps: Download dependencies
deps:
@ -290,6 +338,17 @@ docker-clean:
docker compose -f docker/docker-compose.full.yml down -v
docker rmi picoclaw:latest picoclaw:full 2>/dev/null || true
## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window)
build-macos-app:build-launcher
@echo "Building macOS .app bundle..."
@if [ "$(UNAME_S)" != "Darwin" ]; then \
echo "Error: This target is only available on macOS"; \
exit 1; \
fi
@./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH)
@echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app"
## help: Show this help message
help:
@echo "picoclaw Makefile"

File diff suppressed because it is too large Load diff

618
README.id.md Normal file
View file

@ -0,0 +1,618 @@
<div align="center">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Asisten AI Super Ringan berbasis Go</h1>
<h3>Perangkat Keras $10 · RAM 10MB · Boot ms · Let's Go, PicoClaw!</h3>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Malay](README.my.md) | [English](README.md) | **Bahasa Indonesia**
</div>
---
> **PicoClaw** adalah proyek open-source independen yang diinisiasi oleh [Sipeed](https://sipeed.com), ditulis sepenuhnya dalam **Go** — bukan fork dari OpenClaw, NanoBot, atau proyek lainnya.
**PicoClaw** adalah asisten AI pribadi yang super ringan, terinspirasi dari [NanoBot](https://github.com/HKUDS/nanobot). Dibangun ulang dari awal dalam **Go** melalui proses "self-bootstrapping" — AI Agent itu sendiri yang memandu migrasi arsitektur dan optimasi kode.
**Berjalan di perangkat keras $10 dengan RAM <10MB** hemat 99% memori dibanding OpenClaw dan 98% lebih murah dari Mac mini!
<table align="center">
<tr align="center">
<td align="center" valign="top">
<p align="center">
<img src="assets/picoclaw_mem.gif" width="360" height="240">
</p>
</td>
<td align="center" valign="top">
<p align="center">
<img src="assets/licheervnano.png" width="400" height="240">
</p>
</td>
</tr>
</table>
> [!CAUTION]
> **Peringatan Keamanan**
>
> * **TANPA KRIPTO:** PicoClaw **tidak** menerbitkan token atau cryptocurrency resmi apa pun. Semua klaim di `pump.fun` atau platform trading lainnya adalah **penipuan**.
> * **DOMAIN RESMI:** Satu-satunya website resmi adalah **[picoclaw.io](https://picoclaw.io)**, dan website perusahaan adalah **[sipeed.com](https://sipeed.com)**
> * **WASPADA:** Banyak domain `.ai/.org/.com/.net/...` telah didaftarkan oleh pihak ketiga. Jangan percaya mereka.
> * **CATATAN:** PicoClaw masih dalam tahap pengembangan awal yang cepat. Mungkin ada masalah keamanan yang belum terselesaikan. Jangan deploy ke produksi sebelum v1.0.
> * **CATATAN:** PicoClaw baru-baru ini menggabungkan banyak PR. Build terbaru mungkin menggunakan RAM 10-20MB. Optimasi sumber daya direncanakan setelah fitur stabil.
## 📢 Berita
2026-03-31 📱 **Dukungan Android!** PicoClaw sekarang berjalan di Android! Unduh APK di [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Dirilis!** Perombakan arsitektur Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keamanan (.security.yml, penyaringan data sensitif), provider baru (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 perbaikan bug. PicoClaw telah mencapai **26K Stars**!
2026-03-17 🚀 **v0.2.3 Dirilis!** UI system tray (Windows & Linux), pelacakan status sub-agent (`spawn_status`), eksperimental Gateway hot-reload, gerbang keamanan Cron, dan 2 perbaikan keamanan. PicoClaw telah mencapai **25K Stars**!
2026-03-09 🎉 **v0.2.1 — Pembaruan terbesar sejauh ini!** Dukungan protokol MCP, 4 channel baru (Matrix/IRC/WeCom/Discord Proxy), 3 provider baru (Kimi/Minimax/Avian), pipeline visi, penyimpanan memori JSONL, perutean model.
2026-02-28 📦 **v0.2.0** dirilis dengan dukungan Docker Compose dan Web UI Launcher.
<details>
<summary>Berita sebelumnya...</summary>
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi channel otomatis dan antarmuka kapabilitas kini aktif.
2026-02-16 🎉 PicoClaw menembus 12K Stars dalam satu minggu! Peran maintainer komunitas dan [Roadmap](ROADMAP.md) resmi diluncurkan.
2026-02-13 🎉 PicoClaw menembus 5000 Stars dalam 4 hari! Roadmap proyek dan grup pengembang sedang dalam proses.
2026-02-09 🎉 **PicoClaw Diluncurkan!** Dibangun dalam 1 hari untuk menghadirkan AI Agent ke perangkat keras $10 dengan RAM <10MB. Let's Go, PicoClaw!
</details>
## ✨ Fitur
🪶 **Super Ringan**: Penggunaan memori inti <10MB 99% lebih kecil dari OpenClaw.*
💰 **Biaya Minimal**: Cukup efisien untuk berjalan di perangkat keras $10 — 98% lebih murah dari Mac mini.
⚡️ **Boot Secepat Kilat**: Startup 400x lebih cepat. Boot dalam <1 detik bahkan di prosesor single-core 0,6GHz.
🌍 **Portabilitas Sejati**: Satu binary untuk RISC-V, ARM, MIPS, dan x86. Satu binary, jalan di mana saja!
🤖 **AI-Bootstrapped**: Implementasi Go native murni — 95% kode inti dihasilkan oleh Agent dengan penyempurnaan human-in-the-loop.
🔌 **Dukungan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent.
👁️ **Pipeline Vision**: Kirim gambar dan file langsung ke Agent — encoding base64 otomatis untuk LLM multimodal.
🧠 **Routing Cerdas**: Routing model berbasis aturan — kueri sederhana diarahkan ke model ringan, menghemat biaya API.
_*Build terbaru mungkin menggunakan 10-20MB karena penggabungan PR yang cepat. Optimasi sumber daya direncanakan. Perbandingan kecepatan boot berdasarkan benchmark single-core 0,8GHz (lihat tabel di bawah)._
<div align="center">
| | OpenClaw | NanoBot | **PicoClaw** |
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
| **Bahasa** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB*** |
| **Waktu Boot**</br>(core 0,8GHz) | >500d | >30d | **<1d** |
| **Biaya** | Mac Mini $599 | Kebanyakan board Linux ~$50 | **Board Linux mana pun**</br>**mulai $10** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
</div>
> **[Daftar Kompatibilitas Hardware](docs/hardware-compatibility.md)** — Lihat semua board yang telah diuji, dari RISC-V $5 hingga Raspberry Pi hingga ponsel Android. Board Anda belum terdaftar? Kirim PR!
<p align="center">
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
</p>
## 🦾 Demonstrasi
### 🛠️ Alur Kerja Asisten Standar
<table align="center">
<tr align="center">
<th><p align="center">Mode Full-Stack Engineer</p></th>
<th><p align="center">Pencatatan & Perencanaan</p></th>
<th><p align="center">Pencarian Web & Pembelajaran</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
</tr>
<tr>
<td align="center">Develop · Deploy · Scale</td>
<td align="center">Jadwal · Otomasi · Ingat</td>
<td align="center">Temukan · Wawasan · Tren</td>
</tr>
</table>
### 🐜 Deploy Inovatif dengan Footprint Rendah
PicoClaw dapat di-deploy di hampir semua perangkat Linux!
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versi E(Ethernet) atau W(WiFi6), untuk home assistant minimal
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), atau $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), untuk operasi server otomatis
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) atau $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), untuk pengawasan cerdas
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
🌟 Lebih Banyak Kasus Deploy Menanti!
## 📦 Instalasi
### Unduh dari picoclaw.io (Direkomendasikan)
Kunjungi **[picoclaw.io](https://picoclaw.io)** — website resmi mendeteksi platform Anda secara otomatis dan menyediakan unduhan satu klik. Tidak perlu memilih arsitektur secara manual.
### Unduh binary yang sudah dikompilasi
Atau, unduh binary untuk platform Anda dari halaman [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
### Build dari source (untuk pengembangan)
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# Build binary inti
make build
# Build Web UI Launcher (diperlukan untuk mode WebUI)
make build-launcher
# Build untuk berbagai platform
make build-all
# Build untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
# Build dan instal
make install
```
**Raspberry Pi Zero 2 W:** Gunakan binary yang sesuai dengan OS Anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk build keduanya.
## 🚀 Panduan Memulai Cepat
### 🌐 WebUI Launcher (Direkomendasikan untuk Desktop)
WebUI Launcher menyediakan antarmuka berbasis browser untuk konfigurasi dan chat. Ini adalah cara termudah untuk memulai — tidak perlu pengetahuan command-line.
**Opsi 1: Klik dua kali (Desktop)**
Setelah mengunduh dari [picoclaw.io](https://picoclaw.io), klik dua kali `picoclaw-launcher` (atau `picoclaw-launcher.exe` di Windows). Browser Anda akan terbuka otomatis di `http://localhost:18800`.
**Opsi 2: Command line**
```bash
picoclaw-launcher
# Buka http://localhost:18800 di browser Anda
```
> [!TIP]
> **Akses jarak jauh / Docker / VM:** Tambahkan flag `-public` untuk mendengarkan di semua antarmuka:
> ```bash
> picoclaw-launcher -public
> ```
<p align="center">
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
</p>
**Memulai:**
Buka WebUI, lalu: **1)** Konfigurasi Provider (tambahkan API key LLM Anda) -> **2)** Konfigurasi Channel (mis. Telegram) -> **3)** Mulai Gateway -> **4)** Chat!
Untuk dokumentasi WebUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
<details>
<summary><b>Docker (alternatif)</b></summary>
```bash
# 1. Clone repo ini
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. Jalankan pertama kali — otomatis membuat docker/data/config.json lalu keluar
# (hanya terpicu ketika config.json dan workspace/ keduanya tidak ada)
docker compose -f docker/docker-compose.yml --profile launcher up
# Container mencetak "First-run setup complete." dan berhenti.
# 3. Atur API key Anda
vim docker/data/config.json
# 4. Mulai
docker compose -f docker/docker-compose.yml --profile launcher up -d
# Buka http://localhost:18800
```
> **Pengguna Docker / VM:** Gateway mendengarkan di `127.0.0.1` secara default. Atur `PICOCLAW_GATEWAY_HOST=0.0.0.0` atau gunakan flag `-public` agar dapat diakses dari host.
```bash
# Cek log
docker compose -f docker/docker-compose.yml logs -f
# Hentikan
docker compose -f docker/docker-compose.yml --profile launcher down
# Update
docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml --profile launcher up -d
```
</details>
<details>
<summary><b>macOS — Peringatan Keamanan saat Pertama Kali Diluncurkan</b></summary>
macOS mungkin memblokir `picoclaw-launcher` saat pertama kali diluncurkan karena diunduh dari internet dan tidak dinotarisasi melalui Mac App Store.
**Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat peringatan keamanan:
<p align="center">
<img src="assets/macos-gatekeeper-warning.jpg" alt="Peringatan macOS Gatekeeper" width="400">
</p>
> *"picoclaw-launcher" Tidak Dapat Dibuka — Apple tidak dapat memverifikasi bahwa "picoclaw-launcher" bebas dari malware yang dapat membahayakan Mac Anda atau mengancam privasi Anda.*
**Langkah 2:** Buka **Pengaturan Sistem****Privasi & Keamanan** → gulir ke bawah ke bagian **Keamanan** → klik **Tetap Buka** → konfirmasi dengan mengklik **Tetap Buka** pada dialog.
<p align="center">
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privasi & Keamanan — Tetap Buka" width="600">
</p>
Setelah langkah satu kali ini, `picoclaw-launcher` akan terbuka secara normal pada peluncuran berikutnya.
</details>
### 💻 TUI Launcher (Direkomendasikan untuk Headless / SSH)
TUI (Terminal UI) Launcher menyediakan antarmuka terminal lengkap untuk konfigurasi dan manajemen. Ideal untuk server, Raspberry Pi, dan lingkungan headless lainnya.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Memulai:**
Gunakan menu TUI untuk: **1)** Konfigurasi Provider -> **2)** Konfigurasi Channel -> **3)** Mulai Gateway -> **4)** Chat!
Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
### 📱 Android
Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw.
**Opsi 1: Instal APK**
Pratinjau:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux!
**Opsi 2: Termux**
<details>
<summary><b>Terminal Launcher (untuk lingkungan dengan sumber daya terbatas)</b></summary>
1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
2. Jalankan perintah berikut:
```bash
# Unduh rilis terbaru
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
termux-chroot ./picoclaw onboard # chroot menyediakan tata letak filesystem Linux standar
```
Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi.
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON.
**1. Inisialisasi**
```bash
picoclaw onboard
```
Ini membuat `~/.picoclaw/config.json` dan direktori workspace.
**2. Konfigurasi** (`~/.picoclaw/config.json`)
```json
{
"agents": {
"defaults": {
"model_name": "gpt-5.4"
}
},
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-api-key"
}
]
}
```
> Lihat `config/config.example.json` di repo untuk template konfigurasi lengkap dengan semua opsi yang tersedia.
**3. Chat**
```bash
# Pertanyaan satu kali
picoclaw agent -m "What is 2+2?"
# Mode interaktif
picoclaw agent
# Mulai gateway untuk integrasi aplikasi chat
picoclaw gateway
```
</details>
## 🔌 Providers (LLM)
PicoClaw mendukung 30+ provider LLM melalui konfigurasi `model_list`. Gunakan format `protocol/model`:
| Provider | Protocol | API Key | Catatan |
|----------|----------|---------|---------|
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Diperlukan | GPT-5.4, GPT-4o, o3, dll. |
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Diperlukan | Claude Opus 4.6, Sonnet 4.6, dll. |
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Diperlukan | Gemini 3 Flash, 2.5 Pro, dll. |
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Diperlukan | 200+ model, API terpadu |
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Diperlukan | GLM-4.7, GLM-5, dll. |
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Diperlukan | DeepSeek-V3, DeepSeek-R1 |
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Diperlukan | Doubao, model Ark |
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Diperlukan | Qwen3, Qwen-Max, dll. |
| [Groq](https://console.groq.com/keys) | `groq/` | Diperlukan | Inferensi cepat (Llama, Mixtral) |
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Diperlukan | Model Kimi |
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Diperlukan | Model MiniMax |
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Diperlukan | Mistral Large, Codestral |
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Diperlukan | Model yang di-host NVIDIA |
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Diperlukan | Inferensi cepat |
| [Novita AI](https://novita.ai/) | `novita/` | Diperlukan | Berbagai model open |
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Diperlukan | Model MiMo |
| [Ollama](https://ollama.com/) | `ollama/` | Tidak perlu | Model lokal, self-hosted |
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Tidak perlu | Deploy lokal, kompatibel OpenAI |
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Bervariasi | Proxy untuk 100+ provider |
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Diperlukan | Deploy Azure enterprise |
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login dengan device code |
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
<details>
<summary><b>Deploy lokal (Ollama, vLLM, dll.)</b></summary>
**Ollama:**
```json
{
"model_list": [
{
"model_name": "local-llama",
"model": "ollama/llama3.1:8b",
"api_base": "http://localhost:11434/v1"
}
]
}
```
**vLLM:**
```json
{
"model_list": [
{
"model_name": "local-vllm",
"model": "vllm/your-model",
"api_base": "http://localhost:8000/v1"
}
]
}
```
Untuk detail konfigurasi provider lengkap, lihat [Providers & Models](docs/providers.md).
</details>
## 💬 Channels (Aplikasi Chat)
Bicara dengan PicoClaw Anda melalui 17+ platform pesan:
| Channel | Pengaturan | Protocol | Dokumentasi |
|---------|------------|----------|-------------|
| **Telegram** | Mudah (bot token) | Long polling | [Panduan](docs/channels/telegram/README.md) |
| **Discord** | Mudah (bot token + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
| **WhatsApp** | Mudah (scan QR atau bridge URL) | Native / Bridge | [Panduan](docs/chat-apps.md#whatsapp) |
| **Weixin** | Mudah (scan QR native) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
| **Slack** | Mudah (bot + app token) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
| **Matrix** | Sedang (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
| **DingTalk** | Sedang (client credentials) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
| **Feishu / Lark** | Sedang (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
| **LINE** | Sedang (credentials + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
| **WeCom** | Mudah (login QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) |
| **IRC** | Sedang (server + nick) | IRC protocol | [Panduan](docs/chat-apps.md#irc) |
| **OneBot** | Sedang (WebSocket URL) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
| **Pico** | Mudah (aktifkan) | Native protocol | Bawaan |
| **Pico Client** | Mudah (WebSocket URL) | WebSocket | Bawaan |
> Semua channel berbasis webhook berbagi satu server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu menggunakan mode WebSocket/SDK dan tidak menggunakan server HTTP bersama.
> Verbositas log dikontrol oleh `gateway.log_level` (default: `warn`). Nilai yang didukung: `debug`, `info`, `warn`, `error`, `fatal`. Juga dapat diatur melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk detail.
Untuk instruksi pengaturan channel lengkap, lihat [Konfigurasi Aplikasi Chat](docs/chat-apps.md).
## 🔧 Tools
### 🔍 Pencarian Web
PicoClaw dapat mencari web untuk memberikan informasi terkini. Konfigurasi di `tools.web`:
| Mesin Pencari | API Key | Tier Gratis | Tautan |
|--------------|---------|-------------|--------|
| DuckDuckGo | Tidak perlu | Tidak terbatas | Fallback bawaan |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 kueri/hari | Bertenaga AI, dioptimalkan untuk bahasa Mandarin |
| [Tavily](https://tavily.com) | Diperlukan | 1000 kueri/bulan | Dioptimalkan untuk AI Agent |
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 kueri/bulan | Cepat dan privat |
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Pencarian bertenaga AI |
| [SearXNG](https://github.com/searxng/searxng) | Tidak perlu | Self-hosted | Mesin metasearch gratis |
| [GLM Search](https://open.bigmodel.cn/) | Diperlukan | Bervariasi | Pencarian web Zhipu |
### ⚙️ Tools Lainnya
PicoClaw menyertakan tools bawaan untuk operasi file, eksekusi kode, penjadwalan, dan lainnya. Lihat [Konfigurasi Tools](docs/tools_configuration.md) untuk detail.
## 🎯 Skills
Skills adalah kapabilitas modular yang memperluas Agent Anda. Dimuat dari file `SKILL.md` di workspace Anda.
**Instal skills dari ClawHub:**
```bash
picoclaw skills search "web scraping"
picoclaw skills install <skill-name>
```
**Konfigurasi token ClawHub** (opsional, untuk rate limit lebih tinggi):
Tambahkan ke `config.json` Anda:
```json
{
"tools": {
"skills": {
"registries": {
"clawhub": {
"auth_token": "your-clawhub-token"
}
}
}
}
}
```
Untuk detail lebih lanjut, lihat [Konfigurasi Tools - Skills](docs/tools_configuration.md#skills-tool).
## 🔗 MCP (Model Context Protocol)
PicoClaw mendukung [MCP](https://modelcontextprotocol.io/) secara native — hubungkan server MCP mana pun untuk memperluas kapabilitas Agent Anda dengan tools dan sumber data eksternal.
```json
{
"tools": {
"mcp": {
"enabled": true,
"servers": {
"filesystem": {
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
}
}
```
Untuk konfigurasi MCP lengkap (transport stdio, SSE, HTTP, Tool Discovery), lihat [Konfigurasi Tools - MCP](docs/tools_configuration.md#mcp-tool).
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Bergabung dengan Jaringan Sosial Agent
Hubungkan PicoClaw ke Jaringan Sosial Agent hanya dengan mengirim satu pesan melalui CLI atau Aplikasi Chat terintegrasi mana pun.
**Baca `https://clawdchat.ai/skill.md` dan ikuti instruksi untuk bergabung dengan [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ Referensi CLI
| Perintah | Deskripsi |
| -------------------------- | -------------------------------- |
| `picoclaw onboard` | Inisialisasi konfigurasi & workspace |
| `picoclaw auth weixin` | Hubungkan akun WeChat via QR |
| `picoclaw agent -m "..."` | Chat dengan agent |
| `picoclaw agent` | Mode chat interaktif |
| `picoclaw gateway` | Mulai gateway |
| `picoclaw status` | Tampilkan status |
| `picoclaw version` | Tampilkan info versi |
| `picoclaw model` | Lihat atau ganti model default |
| `picoclaw cron list` | Daftar semua tugas terjadwal |
| `picoclaw cron add ...` | Tambah tugas terjadwal |
| `picoclaw cron disable` | Nonaktifkan tugas terjadwal |
| `picoclaw cron remove` | Hapus tugas terjadwal |
| `picoclaw skills list` | Daftar skill yang terinstal |
| `picoclaw skills install` | Instal skill |
| `picoclaw migrate` | Migrasi data dari versi lama |
| `picoclaw auth login` | Autentikasi dengan provider |
### ⏰ Tugas Terjadwal / Pengingat
PicoClaw mendukung pengingat terjadwal dan tugas berulang melalui tool `cron`:
* **Pengingat satu kali**: "Ingatkan saya dalam 10 menit" -> terpicu sekali setelah 10 menit
* **Tugas berulang**: "Ingatkan saya setiap 2 jam" -> terpicu setiap 2 jam
* **Ekspresi cron**: "Ingatkan saya jam 9 pagi setiap hari" -> menggunakan ekspresi cron
## 📚 Dokumentasi
Untuk panduan lengkap di luar README ini:
| Topik | Deskripsi |
|-------|-----------|
| [Docker & Panduan Cepat](docs/docker.md) | Pengaturan Docker Compose, mode Launcher/Agent |
| [Aplikasi Chat](docs/chat-apps.md) | Semua 17+ panduan pengaturan channel |
| [Konfigurasi](docs/configuration.md) | Variabel environment, tata letak workspace, sandbox keamanan |
| [Providers & Models](docs/providers.md) | 30+ provider LLM, routing model, konfigurasi model_list |
| [Spawn & Tugas Async](docs/spawn-tasks.md) | Tugas cepat, tugas panjang dengan spawn, orkestrasi sub-agent async |
| [Hooks](docs/hooks/README.md) | Sistem hook berbasis event: observer, interceptor, approval hook |
| [Steering](docs/steering.md) | Menyuntikkan pesan ke dalam loop agent yang sedang berjalan |
| [SubTurn](docs/subturn.md) | Koordinasi subagent, kontrol konkurensi, siklus hidup |
| [Pemecahan Masalah](docs/troubleshooting.md) | Masalah umum dan solusinya |
| [Konfigurasi Tools](docs/tools_configuration.md) | Aktifkan/nonaktifkan per-tool, kebijakan exec, MCP, Skills |
| [Kompatibilitas Hardware](docs/hardware-compatibility.md) | Board yang telah diuji, persyaratan minimum |
## 🤝 Kontribusi & Roadmap
PR sangat diterima! Codebase sengaja dibuat kecil dan mudah dibaca.
Lihat [Roadmap Komunitas](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan.
Grup pengembang sedang dibangun, bergabunglah setelah PR pertama Anda di-merge!
Grup Pengguna:
Discord: <https://discord.gg/V4sAZ9XWpN>
WeChat:
<img src="assets/wechat.png" alt="Kode QR grup WeChat" width="512">

617
README.it.md Normal file
View file

@ -0,0 +1,617 @@
<div align="center">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Assistente IA Ultra-Efficiente in Go</h1>
<h3>Hardware da $10 · 10MB di RAM · Avvio in ms · Let's Go, PicoClaw!</h3>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **Italiano** | [Bahasa Indonesia](README.id.md) | [Malay](README.my.md) | [English](README.md)
</div>
---
> **PicoClaw** è un progetto open-source indipendente avviato da [Sipeed](https://sipeed.com), scritto interamente in **Go** da zero — non è un fork di OpenClaw, NanoBot o di qualsiasi altro progetto.
**PicoClaw** è un assistente IA personale ultra-leggero ispirato a [NanoBot](https://github.com/HKUDS/nanobot). È stato riscritto da zero in **Go** attraverso un processo di "auto-bootstrapping" — l'Agent IA stesso ha guidato la migrazione architetturale e l'ottimizzazione del codice.
**Funziona su hardware da $10 con <10MB di RAM** il 99% di memoria in meno rispetto a OpenClaw e il 98% più economico di un Mac mini!
<table align="center">
<tr align="center">
<td align="center" valign="top">
<p align="center">
<img src="assets/picoclaw_mem.gif" width="360" height="240">
</p>
</td>
<td align="center" valign="top">
<p align="center">
<img src="assets/licheervnano.png" width="400" height="240">
</p>
</td>
</tr>
</table>
> [!CAUTION]
> **Avviso di Sicurezza**
>
> * **NESSUNA CRYPTO:** PicoClaw **non** ha emesso token o criptovalute ufficiali. Qualsiasi annuncio su `pump.fun` o altre piattaforme di trading è una **truffa**.
> * **DOMINIO UFFICIALE:** L'**UNICO** sito ufficiale è **[picoclaw.io](https://picoclaw.io)**, e il sito aziendale è **[sipeed.com](https://sipeed.com)**
> * **ATTENZIONE:** Molti domini `.ai/.org/.com/.net/...` sono stati registrati da terze parti. Non fidarti di essi.
> * **NOTA:** PicoClaw è in fase di sviluppo iniziale rapido. Potrebbero esserci problemi di sicurezza non risolti. Non distribuire in produzione prima della v1.0.
> * **NOTA:** PicoClaw ha recentemente unito molte PR. Le build recenti potrebbero usare 10-20MB di RAM. L'ottimizzazione delle risorse è pianificata dopo la stabilizzazione delle funzionalità.
## 📢 Novità
2026-03-31 📱 **Supporto Android!** PicoClaw ora funziona su Android! Scarica l'APK su [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 rilasciata!** Revisione dell'architettura Agent (SubTurn, Hooks, Steering, EventBus), integrazione WeChat/WeCom, rafforzamento della sicurezza (.security.yml, filtraggio dati sensibili), nuovi provider (AWS Bedrock, Azure, Xiaomi MiMo) e 35 correzioni di bug. PicoClaw raggiunge **26K Stars**!
2026-03-17 🚀 **v0.2.3 rilasciata!** Interfaccia system tray (Windows & Linux), query sullo stato dei sub-agent (`spawn_status`), hot-reload sperimentale del Gateway, gate di sicurezza per Cron e 2 correzioni di sicurezza. PicoClaw raggiunge **25K Stars**!
2026-03-09 🎉 **v0.2.1 — Il più grande aggiornamento di sempre!** Supporto al protocollo MCP, 4 nuovi canali (Matrix/IRC/WeCom/Discord Proxy), 3 nuovi provider (Kimi/Minimax/Avian), pipeline visiva, archivio memoria JSONL, routing dei modelli.
2026-02-28 📦 **v0.2.0** rilasciata con supporto Docker Compose e Web UI Launcher.
<details>
<summary>Notizie precedenti...</summary>
2026-02-26 🎉 PicoClaw raggiunge **20K stelle** in soli 17 giorni! Orchestrazione automatica dei canali e interfacce di capacità sono attive.
2026-02-16 🎉 PicoClaw supera 12K stelle in una settimana! Ruoli di maintainer della community e [Roadmap](ROADMAP.md) pubblicati ufficialmente.
2026-02-13 🎉 PicoClaw supera 5000 stelle in 4 giorni! Roadmap del progetto e gruppi sviluppatori in fase di avvio.
2026-02-09 🎉 **PicoClaw lanciato!** Costruito in 1 giorno per portare gli AI Agent su hardware da $10 con <10MB di RAM. Let's Go, PicoClaw!
</details>
## ✨ Caratteristiche
🪶 **Ultra-Leggero**: Impronta di memoria <10MB il 99% più piccolo rispetto a OpenClaw.*
💰 **Costo Minimo**: Abbastanza efficiente da girare su hardware da $10 — il 98% più economico di un Mac mini.
⚡️ **Avvio Fulmineo**: Avvio 400 volte più veloce. Boot in meno di 1 secondo anche su un singolo core a 0,6 GHz.
🌍 **Vera Portabilità**: Singolo binario per RISC-V, ARM, MIPS e x86. Un binario, funziona ovunque!
🤖 **Auto-Costruito dall'IA**: Implementazione nativa in Go — il 95% del codice core è stato generato da un Agent e perfezionato tramite revisione umana nel ciclo.
🔌 **Supporto MCP**: Integrazione nativa del [Model Context Protocol](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità dell'Agent.
👁️ **Pipeline di Visione**: Invia immagini e file direttamente all'Agent — codifica base64 automatica per LLM multimodali.
🧠 **Routing Intelligente**: Routing dei modelli basato su regole — le query semplici vanno verso modelli leggeri, risparmiando sui costi API.
_*Le build recenti potrebbero usare 10-20MB a causa delle fusioni rapide di PR. L'ottimizzazione delle risorse è pianificata. Il confronto dell'avvio è basato su benchmark con singolo core a 0,8 GHz (vedi tabella sotto)._
<div align="center">
| | OpenClaw | NanoBot | **PicoClaw** |
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
| **Linguaggio** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB*** |
| **Avvio**</br>(core 0,8 GHz) | >500s | >30s | **<1s** |
| **Costo** | Mac Mini $599 | La maggior parte degli SBC Linux ~$50 | **Qualsiasi scheda Linux**</br>**a partire da $10** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
</div>
> **[Lista di Compatibilità Hardware](docs/hardware-compatibility.md)** — Vedi tutte le schede testate, dai $5 RISC-V al Raspberry Pi ai telefoni Android. La tua scheda non è elencata? Invia una PR!
<p align="center">
<img src="assets/hardware-banner.jpg" alt="PicoClaw Hardware Compatibility" width="100%">
</p>
## 🦾 Dimostrazione
### 🛠️ Flussi di Lavoro Standard dell'Assistente
<table align="center">
<tr align="center">
<th><p align="center">Modalità Ingegnere Full-Stack</p></th>
<th><p align="center">Log & Pianificazione</p></th>
<th><p align="center">Ricerca Web & Apprendimento</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
</tr>
<tr>
<td align="center">Sviluppa · Distribuisci · Scala</td>
<td align="center">Pianifica · Automatizza · Memorizza</td>
<td align="center">Scopri · Analizza · Tendenze</td>
</tr>
</table>
### 🐜 Deploy Innovativo a Bassa Impronta
PicoClaw può essere distribuito su quasi qualsiasi dispositivo Linux!
- $9,9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versione E (Ethernet) o W (WiFi6), per un assistente domotico minimale
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), o $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), per la manutenzione automatizzata dei server
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) o $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), per la sorveglianza intelligente
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
🌟 Molti altri scenari di deploy ti aspettano!
## 📦 Installazione
### Scarica da picoclaw.io (Consigliato)
Visita **[picoclaw.io](https://picoclaw.io)** — il sito ufficiale rileva automaticamente la tua piattaforma e fornisce il download con un clic. Non è necessario scegliere manualmente l'architettura.
### Scarica il binario precompilato
In alternativa, scarica il binario per la tua piattaforma dalla pagina delle [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
### Compila dai sorgenti (per lo sviluppo)
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# Compila il binario core
make build
# Compila il Web UI Launcher (necessario per la modalità WebUI)
make build-launcher
# Compila per più piattaforme
make build-all
# Compila per Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
# Compila e installa
make install
```
**Raspberry Pi Zero 2 W:** Usa il binario che corrisponde al tuo OS: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Oppure esegui `make build-pi-zero` per compilare entrambi.
## 🚀 Guida Rapida
### 🌐 WebUI Launcher (Consigliato per Desktop)
Il WebUI Launcher fornisce un'interfaccia basata su browser per la configurazione e la chat. È il modo più semplice per iniziare — non è richiesta alcuna conoscenza della riga di comando.
**Opzione 1: Doppio clic (Desktop)**
Dopo aver scaricato da [picoclaw.io](https://picoclaw.io), fai doppio clic su `picoclaw-launcher` (o `picoclaw-launcher.exe` su Windows). Il browser si aprirà automaticamente su `http://localhost:18800`.
**Opzione 2: Riga di comando**
```bash
picoclaw-launcher
# Apri http://localhost:18800 nel browser
```
> [!TIP]
> **Accesso remoto / Docker / VM:** Aggiungi il flag `-public` per ascoltare su tutte le interfacce:
> ```bash
> picoclaw-launcher -public
> ```
<p align="center">
<img src="assets/launcher-webui.jpg" alt="WebUI Launcher" width="600">
</p>
**Per iniziare:**
Apri il WebUI, poi: **1)** Configura un Provider (aggiungi la tua API key LLM) -> **2)** Configura un Channel (es. Telegram) -> **3)** Avvia il Gateway -> **4)** Chatta!
Per la documentazione dettagliata del WebUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
<details>
<summary><b>Docker (alternativa)</b></summary>
```bash
# 1. Clona questo repo
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. Prima esecuzione — genera automaticamente docker/data/config.json poi si ferma
# (si attiva solo quando sia config.json che workspace/ sono assenti)
docker compose -f docker/docker-compose.yml --profile launcher up
# Il container stampa "First-run setup complete." e si ferma.
# 3. Imposta le tue API key
vim docker/data/config.json
# 4. Avvia
docker compose -f docker/docker-compose.yml --profile launcher up -d
# Apri http://localhost:18800
```
> **Utenti Docker / VM:** Il Gateway ascolta su `127.0.0.1` per impostazione predefinita. Imposta `PICOCLAW_GATEWAY_HOST=0.0.0.0` o usa il flag `-public` per renderlo accessibile dall'host.
```bash
# Controlla i log
docker compose -f docker/docker-compose.yml logs -f
# Ferma
docker compose -f docker/docker-compose.yml --profile launcher down
# Aggiorna
docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml --profile launcher up -d
```
</details>
<details>
<summary><b>macOS — Avviso di sicurezza al primo avvio</b></summary>
macOS potrebbe bloccare `picoclaw-launcher` al primo avvio perché è stato scaricato da internet e non è notarizzato tramite il Mac App Store.
**Passo 1:** Fai doppio clic su `picoclaw-launcher`. Verrà visualizzato un avviso di sicurezza:
<p align="center">
<img src="assets/macos-gatekeeper-warning.jpg" alt="Avviso macOS Gatekeeper" width="400">
</p>
> *"picoclaw-launcher" Non Aperto — Apple non è riuscita a verificare che "picoclaw-launcher" sia privo di malware che potrebbe danneggiare il Mac o compromettere la privacy.*
**Passo 2:** Apri **Impostazioni di Sistema****Privacy e sicurezza** → scorri fino alla sezione **Sicurezza** → clicca su **Apri comunque** → conferma cliccando su **Apri comunque** nella finestra di dialogo.
<p align="center">
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privacy e sicurezza — Apri comunque" width="600">
</p>
Dopo questo passaggio una tantum, `picoclaw-launcher` si aprirà normalmente ai lanci successivi.
</details>
### 💻 TUI Launcher (Consigliato per Headless / SSH)
Il TUI (Terminal UI) Launcher fornisce un'interfaccia terminale completa per la configurazione e la gestione. Ideale per server, Raspberry Pi e altri ambienti headless.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="assets/launcher-tui.jpg" alt="TUI Launcher" width="600">
</p>
**Per iniziare:**
Usa i menu TUI per: **1)** Configurare un Provider -> **2)** Configurare un Channel -> **3)** Avviare il Gateway -> **4)** Chattare!
Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs.picoclaw.io).
### 📱 Android
Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw.
**Opzione 1: Installazione APK**
Anteprima:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux!
**Opzione 2: Termux**
<details>
<summary><b>Terminal Launcher (per ambienti con risorse limitate)</b></summary>
1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play)
2. Esegui i seguenti comandi:
```bash
# Scarica l'ultima release
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
termux-chroot ./picoclaw onboard # chroot fornisce un layout standard del filesystem Linux
```
Poi segui la sezione Terminal Launcher qui sotto per completare la configurazione.
<img src="assets/termux.jpg" alt="PicoClaw on Termux" width="512">
Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON.
**1. Inizializza**
```bash
picoclaw onboard
```
Questo crea `~/.picoclaw/config.json` e la directory workspace.
**2. Configura** (`~/.picoclaw/config.json`)
```json
{
"agents": {
"defaults": {
"model_name": "gpt-5.4"
}
},
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-api-key"
}
]
}
```
> Vedi `config/config.example.json` nel repo per un template di configurazione completo con tutte le opzioni disponibili.
**3. Chatta**
```bash
# Domanda singola
picoclaw agent -m "Quanto fa 2+2?"
# Modalità interattiva
picoclaw agent
# Avvia il gateway per l'integrazione con app di chat
picoclaw gateway
```
</details>
## 🔌 Provider (LLM)
PicoClaw supporta 30+ provider LLM tramite la configurazione `model_list`. Usa il formato `protocollo/modello`:
| Provider | Protocollo | API Key | Note |
|----------|------------|---------|------|
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Richiesta | GPT-5.4, GPT-4o, o3, ecc. |
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Richiesta | Claude Opus 4.6, Sonnet 4.6, ecc. |
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Richiesta | Gemini 3 Flash, 2.5 Pro, ecc. |
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Richiesta | 200+ modelli, API unificata |
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Richiesta | GLM-4.7, GLM-5, ecc. |
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Richiesta | DeepSeek-V3, DeepSeek-R1 |
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Richiesta | Doubao, modelli Ark |
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Richiesta | Qwen3, Qwen-Max, ecc. |
| [Groq](https://console.groq.com/keys) | `groq/` | Richiesta | Inferenza veloce (Llama, Mixtral) |
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Richiesta | Modelli Kimi |
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Richiesta | Modelli MiniMax |
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Richiesta | Mistral Large, Codestral |
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Richiesta | Modelli ospitati NVIDIA |
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Richiesta | Inferenza veloce |
| [Novita AI](https://novita.ai/) | `novita/` | Richiesta | Vari modelli open |
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Richiesta | Modelli MiMo |
| [Ollama](https://ollama.com/) | `ollama/` | Non necessaria | Modelli locali, self-hosted |
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Non necessaria | Deploy locale, compatibile OpenAI |
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Variabile | Proxy per 100+ provider |
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Richiesta | Deploy Azure enterprise |
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Login con device code |
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
<details>
<summary><b>Deploy locale (Ollama, vLLM, ecc.)</b></summary>
**Ollama:**
```json
{
"model_list": [
{
"model_name": "local-llama",
"model": "ollama/llama3.1:8b",
"api_base": "http://localhost:11434/v1"
}
]
}
```
**vLLM:**
```json
{
"model_list": [
{
"model_name": "local-vllm",
"model": "vllm/your-model",
"api_base": "http://localhost:8000/v1"
}
]
}
```
Per i dettagli completi sulla configurazione dei provider, vedi [Provider & Modelli](docs/providers.md).
</details>
## 💬 Channel (App di Chat)
Parla con il tuo PicoClaw attraverso 17+ piattaforme di messaggistica:
| Channel | Configurazione | Protocollo | Docs |
|---------|----------------|------------|------|
| **Telegram** | Facile (bot token) | Long polling | [Guida](docs/channels/telegram/README.md) |
| **Discord** | Facile (bot token + intents) | WebSocket | [Guida](docs/channels/discord/README.md) |
| **WhatsApp** | Facile (QR scan o bridge URL) | Nativo / Bridge | [Guida](docs/chat-apps.md#whatsapp) |
| **Weixin** | Facile (scan QR nativo) | iLink API | [Guida](docs/chat-apps.md#weixin) |
| **QQ** | Facile (AppID + AppSecret) | WebSocket | [Guida](docs/channels/qq/README.md) |
| **Slack** | Facile (bot + app token) | Socket Mode | [Guida](docs/channels/slack/README.md) |
| **Matrix** | Medio (homeserver + token) | Sync API | [Guida](docs/channels/matrix/README.md) |
| **DingTalk** | Medio (credenziali client) | Stream | [Guida](docs/channels/dingtalk/README.md) |
| **Feishu / Lark** | Medio (App ID + Secret) | WebSocket/SDK | [Guida](docs/channels/feishu/README.md) |
| **LINE** | Medio (credenziali + webhook) | Webhook | [Guida](docs/channels/line/README.md) |
| **WeCom** | Facile (login QR o manuale) | WebSocket | [Guida](docs/channels/wecom/README.md) |
| **IRC** | Medio (server + nick) | Protocollo IRC | [Guida](docs/chat-apps.md#irc) |
| **OneBot** | Medio (WebSocket URL) | OneBot v11 | [Guida](docs/channels/onebot/README.md) |
| **MaixCam** | Facile (abilita) | TCP socket | [Guida](docs/channels/maixcam/README.md) |
| **Pico** | Facile (abilita) | Protocollo nativo | Integrato |
| **Pico Client** | Facile (WebSocket URL) | WebSocket | Integrato |
> Tutti i channel basati su webhook condividono un singolo server HTTP Gateway (`gateway.host`:`gateway.port`, default `127.0.0.1:18790`). Feishu usa la modalità WebSocket/SDK e non usa il server HTTP condiviso.
> La verbosità dei log è controllata da `gateway.log_level` (default: `warn`). Valori supportati: `debug`, `info`, `warn`, `error`, `fatal`. Può essere impostato anche tramite `PICOCLAW_LOG_LEVEL`. Vedi [Configurazione](docs/configuration.md#gateway-log-level) per i dettagli.
Per istruzioni dettagliate sulla configurazione dei channel, vedi [Configurazione App di Chat](docs/chat-apps.md).
## 🔧 Strumenti
### 🔍 Ricerca Web
PicoClaw può cercare sul web per fornire informazioni aggiornate. Configura in `tools.web`:
| Motore di Ricerca | API Key | Piano Gratuito | Link |
|-------------------|---------|----------------|------|
| DuckDuckGo | Non necessaria | Illimitato | Fallback integrato |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Richiesta | 1000 query/giorno | IA, ottimizzato per il cinese |
| [Tavily](https://tavily.com) | Richiesta | 1000 query/mese | Ottimizzato per AI Agent |
| [Brave Search](https://brave.com/search/api) | Richiesta | 2000 query/mese | Veloce e privato |
| [Perplexity](https://www.perplexity.ai) | Richiesta | A pagamento | Ricerca potenziata dall'IA |
| [SearXNG](https://github.com/searxng/searxng) | Non necessaria | Self-hosted | Metasearch engine gratuito |
| [GLM Search](https://open.bigmodel.cn/) | Richiesta | Variabile | Ricerca web Zhipu |
### ⚙️ Altri Strumenti
PicoClaw include strumenti integrati per operazioni su file, esecuzione di codice, pianificazione e altro. Vedi [Configurazione degli Strumenti](docs/tools_configuration.md) per i dettagli.
## 🎯 Skill
Le Skill sono capacità modulari che estendono il tuo Agent. Vengono caricate dai file `SKILL.md` nel tuo workspace.
**Installa skill da ClawHub:**
```bash
picoclaw skills search "web scraping"
picoclaw skills install <skill-name>
```
**Configura il token ClawHub** (opzionale, per limiti di frequenza più alti):
Aggiungi al tuo `config.json`:
```json
{
"tools": {
"skills": {
"registries": {
"clawhub": {
"auth_token": "your-clawhub-token"
}
}
}
}
}
```
Per maggiori dettagli, vedi [Configurazione degli Strumenti - Skill](docs/tools_configuration.md#skills-tool).
## 🔗 MCP (Model Context Protocol)
PicoClaw supporta nativamente [MCP](https://modelcontextprotocol.io/) — connetti qualsiasi server MCP per estendere le capacità del tuo Agent con strumenti e sorgenti di dati esterni.
```json
{
"tools": {
"mcp": {
"enabled": true,
"servers": {
"filesystem": {
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
}
}
```
Per la configurazione MCP completa (trasporti stdio, SSE, HTTP, Tool Discovery), vedi [Configurazione degli Strumenti - MCP](docs/tools_configuration.md#mcp-tool).
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Unisciti al Social Network degli Agent
Connetti PicoClaw al Social Network degli Agent semplicemente inviando un singolo messaggio tramite CLI o qualsiasi app di chat integrata.
**Leggi `https://clawdchat.ai/skill.md` e segui le istruzioni per unirti a [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ Riferimento CLI
| Comando | Descrizione |
| ------------------------- | ---------------------------------- |
| `picoclaw onboard` | Inizializza config & workspace |
| `picoclaw auth weixin` | Connetti account WeChat tramite QR |
| `picoclaw agent -m "..."` | Chatta con l'agent |
| `picoclaw agent` | Modalità chat interattiva |
| `picoclaw gateway` | Avvia il gateway |
| `picoclaw status` | Mostra lo stato |
| `picoclaw version` | Mostra le info sulla versione |
| `picoclaw model` | Visualizza o cambia il modello predefinito |
| `picoclaw cron list` | Elenca tutti i job pianificati |
| `picoclaw cron add ...` | Aggiunge un job pianificato |
| `picoclaw cron disable` | Disabilita un job pianificato |
| `picoclaw cron remove` | Rimuove un job pianificato |
| `picoclaw skills list` | Elenca le skill installate |
| `picoclaw skills install` | Installa una skill |
| `picoclaw migrate` | Migra i dati dalle versioni precedenti |
| `picoclaw auth login` | Autenticazione con i provider |
### ⏰ Task Pianificati / Promemoria
PicoClaw supporta promemoria pianificati e task ricorrenti tramite lo strumento `cron`:
* **Promemoria una tantum**: "Ricordami tra 10 minuti" -> si attiva una volta dopo 10 min
* **Task ricorrenti**: "Ricordami ogni 2 ore" -> si attiva ogni 2 ore
* **Espressioni cron**: "Ricordami alle 9 ogni giorno" -> usa un'espressione cron
## 📚 Documentazione
Per guide dettagliate oltre questo README:
| Argomento | Descrizione |
|-----------|-------------|
| [Docker & Avvio Rapido](docs/docker.md) | Configurazione Docker Compose, modalità Launcher/Agent |
| [App di Chat](docs/chat-apps.md) | Tutte le guide di configurazione per 17+ channel |
| [Configurazione](docs/configuration.md) | Variabili d'ambiente, struttura del workspace, sandbox di sicurezza |
| [Provider & Modelli](docs/providers.md) | 30+ provider LLM, routing dei modelli, configurazione model_list |
| [Spawn & Task Asincroni](docs/spawn-tasks.md) | Task veloci, task lunghi con spawn, orchestrazione asincrona di sub-agent |
| [Hooks](docs/hooks/README.md) | Sistema di hook event-driven: observer, interceptor, approval hook |
| [Steering](docs/steering.md) | Iniettare messaggi in un loop agent in esecuzione |
| [SubTurn](docs/subturn.md) | Coordinamento subagent, controllo concorrenza, ciclo di vita |
| [Risoluzione Problemi](docs/troubleshooting.md) | Problemi comuni e soluzioni |
| [Configurazione degli Strumenti](docs/tools_configuration.md) | Abilitazione/disabilitazione per strumento, politiche exec, MCP, Skill |
| [Compatibilità Hardware](docs/hardware-compatibility.md) | Schede testate, requisiti minimi |
## 🤝 Contribuisci & Roadmap
Le PR sono benvenute! Il codice è volutamente piccolo e leggibile.
Consulta la nostra [Roadmap della Community](https://github.com/sipeed/picoclaw/issues/988) e [CONTRIBUTING.md](CONTRIBUTING.md) per le linee guida.
Gruppo sviluppatori in costruzione, unisciti dopo la tua prima PR accettata!
Gruppi utenti:
Discord: <https://discord.gg/V4sAZ9XWpN>
WeChat:
<img src="assets/wechat.png" alt="WeChat group QR code" width="512">

File diff suppressed because it is too large Load diff

1763
README.md

File diff suppressed because it is too large Load diff

614
README.my.md Normal file
View file

@ -0,0 +1,614 @@
<div align="center">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Pembantu AI Ultra-Cekap dalam Go</h1>
<h3>Perkakasan $10 · RAM 10MB · Boot ms · Jom, PicoClaw!</h3>
<p>
<img src="https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white" alt="Go">
<img src="https://img.shields.io/badge/Arch-x86__64%2C%20ARM64%2C%20MIPS%2C%20RISC--V%2C%20LoongArch-blue" alt="Hardware">
<img src="https://img.shields.io/badge/license-MIT-green" alt="License">
<br>
<a href="https://picoclaw.io"><img src="https://img.shields.io/badge/Website-picoclaw.io-blue?style=flat&logo=google-chrome&logoColor=white" alt="Website"></a>
<a href="https://docs.picoclaw.io/"><img src="https://img.shields.io/badge/Docs-Official-007acc?style=flat&logo=read-the-docs&logoColor=white" alt="Docs"></a>
<a href="https://deepwiki.com/sipeed/picoclaw"><img src="https://img.shields.io/badge/Wiki-DeepWiki-FFA500?style=flat&logo=wikipedia&logoColor=white" alt="Wiki"></a>
<br>
<a href="https://x.com/SipeedIO"><img src="https://img.shields.io/badge/X_(Twitter)-SipeedIO-black?style=flat&logo=x&logoColor=white" alt="Twitter"></a>
<a href="./assets/wechat.png"><img src="https://img.shields.io/badge/WeChat-Group-41d56b?style=flat&logo=wechat&logoColor=white"></a>
<a href="https://discord.gg/V4sAZ9XWpN"><img src="https://img.shields.io/badge/Discord-Community-4c60eb?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p>
[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [Italiano](README.it.md) | [Bahasa Indonesia](README.id.md) | **Malay** | [English](README.md)
</div>
---
> **PicoClaw** adalah projek sumber terbuka bebas yang dilancarkan oleh [Sipeed](https://sipeed.com), ditulis sepenuhnya dalam **Go** dari awal — bukan cabang OpenClaw, NanoBot, atau projek lain.
**PicoClaw** adalah pembantu AI peribadi ultra-ringan yang terinspirasi oleh [NanoBot](https://github.com/HKUDS/nanobot). Ia dibina semula dari awal dalam **Go** melalui proses "self-bootstrapping" — AI Agent itu sendiri yang memacu migrasi seni bina dan pengoptimuman kod.
**Berjalan pada perkakasan $10 dengan RAM <10MB** 99% lebih sedikit memori daripada OpenClaw dan 98% lebih murah daripada Mac mini!
<table align="center">
<tr align="center">
<td align="center" valign="top">
<p align="center">
<img src="assets/picoclaw_mem.gif" width="360" height="240">
</p>
</td>
<td align="center" valign="top">
<p align="center">
<img src="assets/licheervnano.png" width="400" height="240">
</p>
</td>
</tr>
</table>
> [!CAUTION]
> **Notis Keselamatan**
>
> * **TIADA KRIPTO:** PicoClaw **tidak** mengeluarkan sebarang token atau mata wang kripto rasmi. Semua tuntutan di `pump.fun` atau platform dagangan lain adalah **penipuan**.
> * **DOMAIN RASMI:** Satu-satunya laman web rasmi ialah **[picoclaw.io](https://picoclaw.io)**, dan laman web syarikat ialah **[sipeed.com](https://sipeed.com)**
> * **BERHATI-HATI:** Banyak domain `.ai/.org/.com/.net/...` telah didaftarkan oleh pihak ketiga. Jangan percayai mereka.
> * **NOTA:** PicoClaw dalam pembangunan pesat awal. Mungkin terdapat isu keselamatan yang belum diselesaikan. Jangan deploy ke pengeluaran sebelum v1.0.
## 📢 Berita
2026-03-31 📱 **Sokongan Android!** PicoClaw sekarang berjalan di Android! Muat turun APK di [picoclaw.io](https://picoclaw.io/download)
2026-03-25 🚀 **v0.2.4 Dikeluarkan!** Penstrukturan semula seni bina Agent (SubTurn, Hooks, Steering, EventBus), integrasi WeChat/WeCom, penguatan keselamatan (.security.yml, penapisan data sensitif), penyedia baharu (AWS Bedrock, Azure, Xiaomi MiMo), dan 35 pembetulan pepijat. PicoClaw mencapai **26K Stars**!
2026-03-17 🚀 **v0.2.3 Dikeluarkan!** UI dulang sistem (Windows & Linux), pertanyaan status sub-agent (`spawn_status`), muat semula panas Gateway eksperimental, kawalan keselamatan Cron, dan 2 pembetulan keselamatan. PicoClaw mencapai **25K Stars**!
2026-03-09 🎉 **v0.2.1 — Kemas kini terbesar setakat ini!** Sokongan protokol MCP, 4 saluran baharu (Matrix/IRC/WeCom/Discord Proxy), 3 penyedia baharu (Kimi/Minimax/Avian), saluran paip visi, storan memori JSONL, penghalaan model.
2026-02-28 📦 **v0.2.0** dikeluarkan dengan sokongan Docker Compose dan Pelancar Web UI.
<details>
<summary>Berita terdahulu...</summary>
2026-02-26 🎉 PicoClaw mencapai **20K Stars** hanya dalam 17 hari! Orkestrasi saluran automatik dan antara muka keupayaan kini aktif.
2026-02-16 🎉 PicoClaw melepasi 12K Stars dalam seminggu! Peranan penyelenggara komuniti dan [Peta Jalan](ROADMAP.md) dilancarkan secara rasmi.
2026-02-13 🎉 PicoClaw melepasi 5000 Stars dalam 4 hari! Peta jalan projek dan kumpulan pembangun sedang dalam proses.
2026-02-09 🎉 **PicoClaw Dikeluarkan!** Dibina dalam 1 hari untuk membawa AI Agent ke perkakasan $10 dengan RAM <10MB. Jom, PicoClaw!
</details>
## ✨ Ciri-ciri
🪶 **Ultra-ringan**: Jejak memori teras <10MB 99% lebih kecil daripada OpenClaw.*
💰 **Kos minimum**: Cukup cekap untuk berjalan pada perkakasan $10 — 98% lebih murah daripada Mac mini.
⚡️ **Boot kilat**: 400x lebih pantas. Boot dalam <1s walaupun pada pemproses teras tunggal 0.6GHz.
🌍 **Benar-benar mudah alih**: Binari tunggal merentasi seni bina RISC-V, ARM, MIPS, dan x86.
🤖 **Dibantu AI**: Pelaksanaan Go tulen — 95% kod teras dijana oleh Agent dan diperhalusi melalui semakan manusia.
🔌 **Sokongan MCP**: Integrasi [Model Context Protocol](https://modelcontextprotocol.io/) natif.
👁️ **Saluran paip visi**: Hantar imej dan fail terus ke Agent — pengekodan base64 automatik untuk LLM multimodal.
🧠 **Penghalaan pintar**: Penghalaan model berasaskan peraturan — pertanyaan mudah ke model ringan, menjimatkan kos API.
_*Binaan terkini mungkin menggunakan 10-20MB disebabkan penggabungan PR yang pesat. Pengoptimuman sumber dirancang. Perbandingan kelajuan boot berdasarkan penanda aras teras tunggal 0.8GHz (lihat jadual di bawah)._
<div align="center">
| | OpenClaw | NanoBot | **PicoClaw** |
| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
| **Bahasa** | TypeScript | Python | **Go** |
| **RAM** | >1GB | >100MB | **< 10MB*** |
| **Masa Boot** (teras 0.8GHz) | >500s | >30s | **<1s** |
| **Kos** | Mac Mini $599 | Kebanyakan papan Linux ~$50 | **Mana-mana papan Linux dari $10** |
<img src="assets/compare.jpg" alt="PicoClaw" width="512">
</div>
> **[Senarai Keserasian Perkakasan](docs/hardware-compatibility.md)** — Lihat semua papan yang diuji, dari RISC-V $5 hingga Raspberry Pi hingga telefon Android.
<p align="center">
<img src="assets/hardware-banner.jpg" alt="Keserasian Perkakasan PicoClaw" width="100%">
</p>
## 🦾 Demonstrasi
### 🛠️ Aliran Kerja Pembantu Standard
<table align="center">
<tr align="center">
<th><p align="center">Mod Jurutera Full-Stack</p></th>
<th><p align="center">Pengelogan & Perancangan</p></th>
<th><p align="center">Carian Web & Pembelajaran</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="assets/picoclaw_code.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_memory.gif" width="240" height="180"></p></td>
<td align="center"><p align="center"><img src="assets/picoclaw_search.gif" width="240" height="180"></p></td>
</tr>
<tr>
<td align="center">Bangun · Deploy · Skala</td>
<td align="center">Jadual · Automatik · Ingat</td>
<td align="center">Temui · Wawasan · Trend</td>
</tr>
</table>
### 🐜 Deployment Jejak Rendah yang Inovatif
PicoClaw boleh digunakan pada hampir mana-mana peranti Linux!
- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) untuk pembantu rumah minimal
- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html) untuk operasi pelayan automatik
- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) untuk pengawasan pintar
<https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4>
🌟 Lebih Banyak Kes Deployment Menanti!
## 📦 Pemasangan
### Muat turun dari picoclaw.io (Disyorkan)
Lawati **[picoclaw.io](https://picoclaw.io)** — laman web rasmi mengesan platform anda secara automatik dan menyediakan muat turun satu klik.
### Muat turun binari pra-kompil
Muat turun binari untuk platform anda dari halaman [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
### Bina dari sumber (untuk pembangunan)
```bash
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
make deps
# Bina binari teras
make build
# Bina Pelancar Web UI (diperlukan untuk mod WebUI)
make build-launcher
# Bina untuk pelbagai platform
make build-all
# Bina untuk Raspberry Pi Zero 2 W (32-bit: make build-linux-arm; 64-bit: make build-linux-arm64)
make build-pi-zero
# Bina dan pasang
make install
```
**Raspberry Pi Zero 2 W:** Gunakan binari yang sepadan dengan OS anda: Raspberry Pi OS 32-bit -> `make build-linux-arm`; 64-bit -> `make build-linux-arm64`. Atau jalankan `make build-pi-zero` untuk membina kedua-duanya.
## 🚀 Panduan Permulaan Pantas
### 🌐 Pelancar WebUI (Disyorkan untuk Desktop)
Pelancar WebUI menyediakan antara muka berasaskan pelayar untuk konfigurasi dan sembang. Ini adalah cara termudah untuk bermula — tiada pengetahuan baris arahan diperlukan.
**Pilihan 1: Klik dua kali (Desktop)**
Selepas memuat turun dari [picoclaw.io](https://picoclaw.io), klik dua kali `picoclaw-launcher` (atau `picoclaw-launcher.exe` pada Windows). Pelayar anda akan dibuka secara automatik di `http://localhost:18800`.
**Pilihan 2: Baris arahan**
```bash
picoclaw-launcher
# Buka http://localhost:18800 dalam pelayar anda
```
> [!TIP]
> **Akses jauh / Docker / VM:** Tambah bendera `-public` untuk mendengar pada semua antara muka:
> ```bash
> picoclaw-launcher -public
> ```
<p align="center">
<img src="assets/launcher-webui.jpg" alt="Pelancar WebUI" width="600">
</p>
**Memulakan:** Buka WebUI, kemudian: **1)** Konfigurasikan Penyedia (tambah kunci API LLM) -> **2)** Konfigurasikan Saluran (cth. Telegram) -> **3)** Mulakan Gateway -> **4)** Sembang!
Untuk dokumentasi WebUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
<details>
<summary><b>Docker (alternatif)</b></summary>
```bash
# 1. Klon repo ini
git clone https://github.com/sipeed/picoclaw.git
cd picoclaw
# 2. Jalankan pertama kali — jana docker/data/config.json secara automatik kemudian keluar
docker compose -f docker/docker-compose.yml --profile launcher up
# 3. Tetapkan kunci API anda
vim docker/data/config.json
# 4. Mulakan
docker compose -f docker/docker-compose.yml --profile launcher up -d
# Buka http://localhost:18800
```
> **Pengguna Docker / VM:** Gateway mendengar pada `127.0.0.1` secara lalai. Tetapkan `PICOCLAW_GATEWAY_HOST=0.0.0.0` atau gunakan bendera `-public` untuk membolehkan akses dari hos.
```bash
# Semak log
docker compose -f docker/docker-compose.yml logs -f
# Henti
docker compose -f docker/docker-compose.yml --profile launcher down
# Kemas kini
docker compose -f docker/docker-compose.yml pull
docker compose -f docker/docker-compose.yml --profile launcher up -d
```
</details>
<details>
<summary><b>macOS — Amaran Keselamatan Pelancaran Pertama</b></summary>
macOS mungkin menyekat `picoclaw-launcher` pada pelancaran pertama kerana ia dimuat turun dari internet dan tidak disahkan melalui Mac App Store.
**Langkah 1:** Klik dua kali `picoclaw-launcher`. Anda akan melihat amaran keselamatan:
<p align="center">
<img src="assets/macos-gatekeeper-warning.jpg" alt="Amaran macOS Gatekeeper" width="400">
</p>
> *"picoclaw-launcher" Tidak Dibuka — Apple tidak dapat mengesahkan "picoclaw-launcher" bebas daripada perisian hasad yang mungkin membahayakan Mac anda atau menjejaskan privasi anda.*
**Langkah 2:** Buka **Tetapan Sistem****Privasi & Keselamatan** → tatal ke bawah ke bahagian **Keselamatan** → klik **Buka Juga** → sahkan dengan mengklik **Buka Juga** dalam dialog.
<p align="center">
<img src="assets/macos-gatekeeper-allow.jpg" alt="macOS Privasi & Keselamatan — Buka Juga" width="600">
</p>
Selepas langkah sekali ini, `picoclaw-launcher` akan dibuka secara normal pada pelancaran seterusnya.
</details>
### 💻 Pelancar TUI (Disyorkan untuk Headless / SSH)
Pelancar TUI menyediakan antara muka terminal lengkap untuk konfigurasi dan pengurusan. Sesuai untuk pelayan, Raspberry Pi, dan persekitaran tanpa kepala lain.
```bash
picoclaw-launcher-tui
```
<p align="center">
<img src="assets/launcher-tui.jpg" alt="Pelancar TUI" width="600">
</p>
**Memulakan:**
Gunakan menu TUI untuk: **1)** Konfigurasikan Penyedia -> **2)** Konfigurasikan Saluran -> **3)** Mulakan Gateway -> **4)** Sembang!
Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw.io).
### 📱 Android
Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw.
**Pilihan 1: Pasang APK**
Pratonton:
<table>
<tr>
<td><img src="assets/fui_main_page.jpg" width="200"></td>
<td><img src="assets/fui_web_page.jpg" width="200"></td>
<td><img src="assets/fui_log_page.jpg" width="200"></td>
<td><img src="assets/fui_setting_page.jpg" width="200"></td>
</tr>
</table>
Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan!
**Pilihan 2: Termux**
<details>
<summary><b>Pelancar Terminal (untuk persekitaran terhad sumber)</b></summary>
1. Pasang [Termux](https://github.com/termux/termux-app) (muat turun dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play)
2. Jalankan arahan berikut:
```bash
# Muat turun keluaran terkini
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
termux-chroot ./picoclaw onboard # chroot menyediakan susun atur sistem fail Linux standard
```
Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfigurasi.
<img src="assets/termux.jpg" alt="PicoClaw pada Termux" width="512">
Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON.
**1. Mulakan**
```bash
picoclaw onboard
```
Ini mencipta `~/.picoclaw/config.json` dan direktori ruang kerja.
**2. Konfigurasikan** (`~/.picoclaw/config.json`)
```json
{
"agents": {
"defaults": {
"model_name": "gpt-5.4"
}
},
"model_list": [
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4"
}
]
}
```
> Lihat `config/config.example.json` dalam repo untuk templat konfigurasi lengkap. Nota: kunci API kini disimpan dalam `.security.yml`, bukan `config.json`.
**3. Sembang**
```bash
picoclaw agent -m "Apa itu 2+2?"
# Mod interaktif
picoclaw agent
# Mulakan gateway untuk integrasi aplikasi sembang
picoclaw gateway
```
</details>
## 🔌 Penyedia (LLM)
PicoClaw menyokong 30+ penyedia LLM melalui konfigurasi `model_list`. Gunakan format `protokol/model`:
| Penyedia | Protokol | Kunci API | Nota |
|----------|----------|-----------|------|
| [OpenAI](https://platform.openai.com/api-keys) | `openai/` | Diperlukan | GPT-5.4, GPT-4o, o3, dll. |
| [Anthropic](https://console.anthropic.com/settings/keys) | `anthropic/` | Diperlukan | Claude Opus 4.6, Sonnet 4.6, dll. |
| [Google Gemini](https://aistudio.google.com/apikey) | `gemini/` | Diperlukan | Gemini 3 Flash, 2.5 Pro, dll. |
| [OpenRouter](https://openrouter.ai/keys) | `openrouter/` | Diperlukan | 200+ model, API bersatu |
| [Zhipu (GLM)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | `zhipu/` | Diperlukan | GLM-4.7, GLM-5, dll. |
| [DeepSeek](https://platform.deepseek.com/api_keys) | `deepseek/` | Diperlukan | DeepSeek-V3, DeepSeek-R1 |
| [Volcengine](https://console.volcengine.com) | `volcengine/` | Diperlukan | Doubao, model Ark |
| [Qwen](https://dashscope.console.aliyun.com/apiKey) | `qwen/` | Diperlukan | Qwen3, Qwen-Max, dll. |
| [Groq](https://console.groq.com/keys) | `groq/` | Diperlukan | Inferens pantas (Llama, Mixtral) |
| [Moonshot (Kimi)](https://platform.moonshot.cn/console/api-keys) | `moonshot/` | Diperlukan | Model Kimi |
| [Minimax](https://platform.minimaxi.com/user-center/basic-information/interface-key) | `minimax/` | Diperlukan | Model MiniMax |
| [Mistral](https://console.mistral.ai/api-keys) | `mistral/` | Diperlukan | Mistral Large, Codestral |
| [NVIDIA NIM](https://build.nvidia.com/) | `nvidia/` | Diperlukan | Model hos NVIDIA |
| [Cerebras](https://cloud.cerebras.ai/) | `cerebras/` | Diperlukan | Inferens pantas |
| [Novita AI](https://novita.ai/) | `novita/` | Diperlukan | Pelbagai model terbuka |
| [Xiaomi MiMo](https://platform.xiaomimimo.com/) | `mimo/` | Diperlukan | Model MiMo |
| [Ollama](https://ollama.com/) | `ollama/` | Tidak perlu | Model tempatan, self-hosted |
| [vLLM](https://docs.vllm.ai/) | `vllm/` | Tidak perlu | Deployment tempatan, serasi OpenAI |
| [LiteLLM](https://docs.litellm.ai/) | `litellm/` | Berbeza | Proksi untuk 100+ penyedia |
| [Azure OpenAI](https://portal.azure.com/) | `azure/` | Diperlukan | Deployment Azure perusahaan |
| [GitHub Copilot](https://github.com/features/copilot) | `github-copilot/` | OAuth | Log masuk kod peranti |
| [Antigravity](https://console.cloud.google.com/) | `antigravity/` | OAuth | Google Cloud AI |
| [AWS Bedrock](https://console.aws.amazon.com/bedrock)* | `bedrock/` | Kelayakan AWS | Claude, Llama, Mistral pada AWS |
> \* AWS Bedrock memerlukan tag binaan: `go build -tags bedrock`. Tetapkan `api_base` kepada nama rantau (cth. `us-east-1`) untuk resolusi endpoint automatik merentasi semua partition AWS. Apabila menggunakan URL endpoint penuh, anda juga perlu mengkonfigurasi `AWS_REGION` melalui pemboleh ubah persekitaran.
<details>
<summary><b>Deployment tempatan (Ollama, vLLM, dll.)</b></summary>
**Ollama:**
```json
{
"model_list": [
{
"model_name": "local-llama",
"model": "ollama/llama3.1:8b",
"api_base": "http://localhost:11434/v1"
}
]
}
```
**vLLM:**
```json
{
"model_list": [
{
"model_name": "local-vllm",
"model": "vllm/your-model",
"api_base": "http://localhost:8000/v1"
}
]
}
```
Untuk butiran konfigurasi penyedia penuh, lihat [Penyedia & Model](docs/providers.md).
</details>
## 💬 Saluran (Aplikasi Sembang)
Bercakap dengan PicoClaw anda melalui 17+ platform pemesejan:
| Saluran | Persediaan | Protokol | Dok |
|---------|-----------|----------|-----|
| **Telegram** | Mudah (token bot) | Long polling | [Panduan](docs/channels/telegram/README.md) |
| **Discord** | Mudah (token bot + intents) | WebSocket | [Panduan](docs/channels/discord/README.md) |
| **WhatsApp** | Mudah (imbas QR atau URL jambatan) | Natif / Jambatan | [Panduan](docs/chat-apps.md#whatsapp) |
| **Weixin** | Mudah (imbas QR natif) | iLink API | [Panduan](docs/chat-apps.md#weixin) |
| **QQ** | Mudah (AppID + AppSecret) | WebSocket | [Panduan](docs/channels/qq/README.md) |
| **Slack** | Mudah (token bot + app) | Socket Mode | [Panduan](docs/channels/slack/README.md) |
| **Matrix** | Sederhana (homeserver + token) | Sync API | [Panduan](docs/channels/matrix/README.md) |
| **DingTalk** | Sederhana (kelayakan klien) | Stream | [Panduan](docs/channels/dingtalk/README.md) |
| **Feishu / Lark** | Sederhana (App ID + Secret) | WebSocket/SDK | [Panduan](docs/channels/feishu/README.md) |
| **LINE** | Sederhana (kelayakan + webhook) | Webhook | [Panduan](docs/channels/line/README.md) |
| **WeCom** | Mudah (log masuk QR atau manual) | WebSocket | [Panduan](docs/channels/wecom/README.md) |
| **IRC** | Sederhana (pelayan + nick) | Protokol IRC | [Panduan](docs/chat-apps.md#irc) |
| **OneBot** | Sederhana (URL WebSocket) | OneBot v11 | [Panduan](docs/channels/onebot/README.md) |
| **MaixCam** | Mudah (aktifkan) | TCP socket | [Panduan](docs/channels/maixcam/README.md) |
| **Pico** | Mudah (aktifkan) | Protokol natif | Terbina dalam |
| **Pico Client** | Mudah (URL WebSocket) | WebSocket | Terbina dalam |
> Semua saluran berasaskan webhook berkongsi satu pelayan HTTP Gateway (`gateway.host`:`gateway.port`, lalai `127.0.0.1:18790`). Feishu menggunakan mod WebSocket/SDK dan tidak menggunakan pelayan HTTP yang dikongsi.
> Tahap perincian log dikawal oleh `gateway.log_level` (lalai: `warn`). Nilai yang disokong: `debug`, `info`, `warn`, `error`, `fatal`. Boleh juga ditetapkan melalui `PICOCLAW_LOG_LEVEL`. Lihat [Konfigurasi](docs/configuration.md#gateway-log-level) untuk butiran.
Untuk arahan persediaan saluran terperinci, lihat [Konfigurasi Aplikasi Sembang](docs/my/chat-apps.md).
## 🔧 Alat
### 🔍 Carian Web
PicoClaw boleh mencari web untuk menyediakan maklumat terkini. Konfigurasikan dalam `tools.web`:
| Enjin Carian | Kunci API | Peringkat Percuma | Pautan |
|-------------|-----------|-------------------|--------|
| DuckDuckGo | Tidak perlu | Tanpa had | Sandaran terbina dalam |
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Diperlukan | 1000 pertanyaan/hari | Dikuasai AI, dioptimumkan untuk China |
| [Tavily](https://tavily.com) | Diperlukan | 1000 pertanyaan/bulan | Dioptimumkan untuk AI Agent |
| [Brave Search](https://brave.com/search/api) | Diperlukan | 2000 pertanyaan/bulan | Pantas dan peribadi |
| [Perplexity](https://www.perplexity.ai) | Diperlukan | Berbayar | Carian dikuasai AI |
| [SearXNG](https://github.com/searxng/searxng) | Tidak perlu | Self-hosted | Enjin metasearch percuma |
| [GLM Search](https://open.bigmodel.cn/) | Diperlukan | Berbeza | Carian web Zhipu |
### ⚙️ Alat Lain
PicoClaw menyertakan alat terbina dalam untuk operasi fail, pelaksanaan kod, penjadualan, dan banyak lagi. Lihat [Konfigurasi Alat](docs/tools_configuration.md) untuk butiran.
## 🎯 Kemahiran
Kemahiran adalah keupayaan modular yang melanjutkan Agent anda. Ia dimuatkan dari fail `SKILL.md` dalam ruang kerja anda.
**Pasang kemahiran dari ClawHub:**
```bash
picoclaw skills search "web scraping"
picoclaw skills install <nama-kemahiran>
```
**Konfigurasikan token ClawHub** (pilihan, untuk had kadar lebih tinggi):
Tambah ke `config.json` anda:
```json
{
"tools": {
"skills": {
"registries": {
"clawhub": {
"auth_token": "your-clawhub-token"
}
}
}
}
}
```
Untuk butiran lanjut, lihat [Konfigurasi Alat - Kemahiran](docs/tools_configuration.md#skills-tool).
## 🔗 MCP (Protokol Konteks Model)
PicoClaw menyokong [MCP](https://modelcontextprotocol.io/) secara natif — sambungkan mana-mana pelayan MCP untuk melanjutkan keupayaan Agent anda dengan alat dan sumber data luaran.
```json
{
"tools": {
"mcp": {
"enabled": true,
"servers": {
"filesystem": {
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
}
}
}
}
}
```
Untuk konfigurasi MCP penuh (pengangkutan stdio, SSE, HTTP, Penemuan Alat), lihat [Konfigurasi Alat - MCP](docs/tools_configuration.md#mcp-tool).
## <img src="assets/clawdchat-icon.png" width="24" height="24" alt="ClawdChat"> Sertai Rangkaian Sosial Agent
Sambungkan PicoClaw ke Rangkaian Sosial Agent dengan menghantar satu mesej melalui CLI atau mana-mana Aplikasi Sembang yang disepadukan.
**Baca `https://clawdchat.ai/skill.md` dan ikuti arahan untuk menyertai [ClawdChat.ai](https://clawdchat.ai)**
## 🖥️ Rujukan CLI
| Arahan | Penerangan |
| ------ | ---------- |
| `picoclaw onboard` | Mulakan konfigurasi & ruang kerja |
| `picoclaw auth weixin` | Sambungkan akaun WeChat melalui QR |
| `picoclaw agent -m "..."` | Sembang dengan agent |
| `picoclaw agent` | Mod sembang interaktif |
| `picoclaw gateway` | Mulakan gateway |
| `picoclaw status` | Tunjukkan status |
| `picoclaw version` | Tunjukkan maklumat versi |
| `picoclaw model` | Lihat atau tukar model lalai |
| `picoclaw cron list` | Senaraikan semua kerja berjadual |
| `picoclaw cron add ...` | Tambah kerja berjadual |
| `picoclaw cron disable` | Lumpuhkan kerja berjadual |
| `picoclaw cron remove` | Buang kerja berjadual |
| `picoclaw skills list` | Senaraikan kemahiran yang dipasang |
| `picoclaw skills install` | Pasang kemahiran |
| `picoclaw migrate` | Migrasi data dari versi lama |
| `picoclaw auth login` | Sahkan dengan penyedia |
### ⏰ Tugasan Berjadual / Peringatan
PicoClaw menyokong peringatan berjadual dan tugasan berulang melalui alat `cron`:
* **Peringatan sekali**: "Ingatkan saya dalam 10 minit" -> pencetus sekali selepas 10 minit
* **Tugasan berulang**: "Ingatkan saya setiap 2 jam" -> pencetus setiap 2 jam
* **Ungkapan Cron**: "Ingatkan saya pada pukul 9 pagi setiap hari" -> menggunakan ungkapan cron
## 📚 Dokumentasi
Untuk panduan terperinci melebihi README ini:
| Topik | Penerangan |
|-------|------------|
| [Docker & Permulaan Pantas](docs/my/docker.md) | Persediaan Docker Compose, mod Launcher/Agent |
| [Aplikasi Sembang](docs/my/chat-apps.md) | Panduan persediaan 17+ saluran |
| [Konfigurasi](docs/my/configuration.md) | Pemboleh ubah persekitaran, susun atur ruang kerja |
| [Penyedia & Model](docs/providers.md) | 30+ penyedia LLM, penghalaan model |
| [Spawn & Tugasan Async](docs/my/spawn-tasks.md) | Tugasan pantas, tugasan panjang dengan spawn |
| [Penyelesaian Masalah](docs/my/troubleshooting.md) | Isu biasa dan penyelesaian |
| [Konfigurasi Alat](docs/tools_configuration.md) | Aktif/nyahaktif alat, dasar exec, MCP, Kemahiran |
| [Keserasian Perkakasan](docs/hardware-compatibility.md) | Papan yang diuji, keperluan minimum |
## 🤝 Sumbangan & Peta Jalan
PR dialu-alukan! Kod sumber sengaja dibuat kecil dan mudah dibaca.
Lihat [Peta Jalan Komuniti](https://github.com/sipeed/picoclaw/issues/988) dan [CONTRIBUTING.md](CONTRIBUTING.md) untuk panduan.
Kumpulan pembangun sedang dibina, sertai selepas PR pertama anda digabungkan!
Kumpulan Pengguna:
Discord: <https://discord.gg/V4sAZ9XWpN>
WeChat:
<img src="assets/wechat.png" alt="Kod QR kumpulan WeChat" width="512">

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

BIN
assets/fui_log_page.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
assets/fui_main_page.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
assets/fui_setting_page.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
assets/fui_web_page.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
assets/hardware-banner.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

BIN
assets/launcher-tui.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 271 KiB

BIN
assets/launcher-webui.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 348 KiB

After

Width:  |  Height:  |  Size: 365 KiB

BIN
assets/wecom-qr-binding.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

View file

@ -0,0 +1,69 @@
# Picoclaw Launcher TUI
This directory contains the terminal-based TUI launcher for `picoclaw`.
It provides a lightweight, terminal-native user interface for managing, configuring, and interacting with the core `picoclaw` engine, without requiring a web browser or graphical environment.
## Architecture
The TUI launcher is implemented purely in Go with no external runtime dependencies:
* **`main.go`**: Application entry point, handles initialization and main event loop
* **`ui/`**: TUI interface components built on tview + tcell framework:
- `home.go`: Main dashboard with navigation menu
- `schemes.go`: AI model scheme management
- `users.go`: User and API key management for model providers
- `channels.go`: Communication channel (Telegram/Discord/WeChat etc.) configuration editor
- `gateway.go`: PicoClaw gateway daemon lifecycle management (start/stop/status)
- `app.go`: Core TUI application framework and navigation logic
- `models.go`: Data structures and state management
* **`config/`**: Configuration management layer, integrates with the core picoclaw configuration system
## Getting Started
### Prerequisites
* Go 1.25+
* Terminal with 256-color support (most modern terminals are compatible)
### Development
Run the TUI launcher directly in development mode:
```bash
# From project root
go run ./cmd/picoclaw-launcher-tui
# Or from this directory
go run .
```
### Build
Build the standalone TUI launcher binary:
```bash
# From project root (recommended)
make build-launcher-tui
# Output will be at:
# build/picoclaw-launcher-tui-<platform>-<arch>
# with symlink build/picoclaw-launcher-tui
# Or build directly from this directory
go build -o picoclaw-launcher-tui .
```
### Key Features
* 🖥️ Terminal-native interface - works over SSH, on headless servers, and in low-resource environments
* ⚙️ AI model scheme and API key management
* 📱 Communication channel configuration editor (Telegram/Discord/WeChat etc.)
* 🔄 PicoClaw gateway daemon management (start/stop/status monitoring)
* 💬 One-click launch of interactive AI chat session
* 🎯 Keyboard-first design with intuitive shortcuts
### Other Commands
```bash
# Run with custom config file path
go run . /path/to/custom/config.json
```

View file

@ -0,0 +1,236 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
// Package config provides types and I/O for ~/.picoclaw/tui.toml.
package config
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/BurntSushi/toml"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
// DefaultConfigPath returns the default path to the tui.toml config file.
func DefaultConfigPath() string {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
return filepath.Join(home, ".picoclaw", "tui.toml")
}
// TUIConfig is the top-level structure of ~/.picoclaw/tui.toml.
type TUIConfig struct {
Version string `toml:"version"`
Model Model `toml:"model"`
Provider Provider `toml:"provider"`
}
type Model struct {
Type string `toml:"type"` // "provider" (default) | "manual"
}
type Provider struct {
Schemes []Scheme `toml:"schemes"`
Users []User `toml:"users"`
Current ProviderCurrent `toml:"current"`
}
type Scheme struct {
Name string `toml:"name"` // unique key
BaseURL string `toml:"baseURL"` // required
Type string `toml:"type"` // "openai-compatible" (default) | "anthropic"
}
type User struct {
Name string `toml:"name"`
Scheme string `toml:"scheme"` // references Scheme.Name; (Name+Scheme) is unique
Type string `toml:"type"` // "key" (default) | "OAuth"
Key string `toml:"key"`
}
type ProviderCurrent struct {
Scheme string `toml:"scheme"` // references Scheme.Name
User string `toml:"user"` // references User.Name where User.Scheme == Scheme
Model string `toml:"model"` // from GET <baseURL>/models
}
// DefaultConfig returns a minimal valid TUIConfig.
func DefaultConfig() *TUIConfig {
return &TUIConfig{
Version: "1.0",
Model: Model{Type: "provider"},
Provider: Provider{
Schemes: []Scheme{},
Users: []User{},
Current: ProviderCurrent{},
},
}
}
// Load reads the TUI config from path. Returns a default config if the file does not exist.
func Load(path string) (*TUIConfig, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return DefaultConfig(), nil
}
if err != nil {
return nil, fmt.Errorf("failed to read config file %q: %w", path, err)
}
cfg := DefaultConfig()
if _, err := toml.Decode(string(data), cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
}
applyDefaults(cfg)
return cfg, nil
}
// Save writes cfg to path atomically (safe for flash / SD storage).
func Save(path string, cfg *TUIConfig) error {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
var buf bytes.Buffer
enc := toml.NewEncoder(&buf)
if err := enc.Encode(cfg); err != nil {
return fmt.Errorf("failed to encode config: %w", err)
}
if err := fileutil.WriteFileAtomic(path, buf.Bytes(), 0o600); err != nil {
return fmt.Errorf("failed to write config file %q: %w", path, err)
}
return nil
}
func applyDefaults(cfg *TUIConfig) {
if cfg.Version == "" {
cfg.Version = "1.0"
}
if cfg.Model.Type == "" {
cfg.Model.Type = "provider"
}
for i := range cfg.Provider.Schemes {
if cfg.Provider.Schemes[i].Type == "" {
cfg.Provider.Schemes[i].Type = "openai-compatible"
}
}
for i := range cfg.Provider.Users {
if cfg.Provider.Users[i].Type == "" {
cfg.Provider.Users[i].Type = "key"
}
}
}
// SchemeByName returns the first Scheme whose Name matches, or nil.
func (p *Provider) SchemeByName(name string) *Scheme {
for i := range p.Schemes {
if p.Schemes[i].Name == name {
return &p.Schemes[i]
}
}
return nil
}
// UsersForScheme returns all users whose Scheme field matches schemeName.
func (p *Provider) UsersForScheme(schemeName string) []User {
var out []User
for _, u := range p.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
// SyncSelectedModelToMainConfig syncs the currently selected model to ~/.picoclaw/config.json
// Adds/replaces a "tui-prefer" model entry and sets it as the default model.
// Preserves all other existing fields in the config file unchanged.
func SyncSelectedModelToMainConfig(scheme Scheme, user User, modelID string) error {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
mainConfigPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, readErr := os.ReadFile(mainConfigPath); readErr == nil {
if unmarshalErr := json.Unmarshal(data, &cfg); unmarshalErr != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["agents"]; !ok {
cfg["agents"] = make(map[string]any)
}
agents, ok := cfg["agents"].(map[string]any)
if ok {
if _, ok := agents["defaults"]; !ok {
agents["defaults"] = make(map[string]any)
}
defaults, ok := agents["defaults"].(map[string]any)
if ok {
defaults["model"] = "tui-prefer"
}
}
tuiModel := map[string]any{
"model_name": "tui-prefer",
"model": modelID,
"api_key": user.Key,
"api_base": scheme.BaseURL,
}
modelList := []any{}
if ml, ok := cfg["model_list"].([]any); ok {
modelList = ml
}
found := false
for i, m := range modelList {
if entry, ok := m.(map[string]any); ok {
if name, ok := entry["model_name"].(string); ok && name == "tui-prefer" {
modelList[i] = tuiModel
found = true
break
}
}
}
if !found {
modelList = append(modelList, tuiModel)
}
cfg["model_list"] = modelList
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(mainConfigPath), 0o700); err != nil {
return err
}
return os.WriteFile(mainConfigPath, data, 0o600)
}
func (cfg *TUIConfig) CurrentModelLabel() string {
cur := cfg.Provider.Current
if cur.Model == "" {
return "(not configured)"
}
label := cur.Scheme
if label != "" {
label += " / "
}
return label + cur.Model
}

View file

@ -1,49 +0,0 @@
package configstore
import (
"errors"
"os"
"path/filepath"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
const (
configDirName = ".picoclaw"
configFileName = "config.json"
)
func ConfigPath() (string, error) {
dir, err := ConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, configFileName), nil
}
func ConfigDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, configDirName), nil
}
func Load() (*picoclawconfig.Config, error) {
path, err := ConfigPath()
if err != nil {
return nil, err
}
return picoclawconfig.LoadConfig(path)
}
func Save(cfg *picoclawconfig.Config) error {
if cfg == nil {
return errors.New("config is nil")
}
path, err := ConfigPath()
if err != nil {
return err
}
return picoclawconfig.SaveConfig(path, cfg)
}

View file

@ -1,522 +0,0 @@
package ui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
configstore "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/config"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
type appState struct {
app *tview.Application
pages *tview.Pages
stack []string
config *picoclawconfig.Config
configPath string
gatewayCmd *exec.Cmd
menus map[string]*Menu
original []byte
hasOriginal bool
backupPath string
dirty bool
logPath string
}
func Run() error {
applyStyles()
cfg, err := configstore.Load()
if err != nil {
return err
}
path, err := configstore.ConfigPath()
if err != nil {
return err
}
if cfg == nil {
cfg = picoclawconfig.DefaultConfig()
}
originalData, hasOriginal := loadOriginalConfig(path)
backupPath := path + ".bak"
if hasOriginal {
_ = writeBackupConfig(backupPath, originalData)
}
logPath := filepath.Join(filepath.Dir(path), "gateway.log")
state := &appState{
app: tview.NewApplication(),
pages: tview.NewPages(),
config: cfg,
configPath: path,
menus: map[string]*Menu{},
original: originalData,
hasOriginal: hasOriginal,
backupPath: backupPath,
logPath: logPath,
}
state.push("main", state.mainMenu())
root := tview.NewFlex().SetDirection(tview.FlexRow)
root.AddItem(bannerView(), 6, 0, false)
root.AddItem(state.pages, 0, 1, true)
root.AddItem(footerView(), 1, 0, false)
if err := state.app.SetRoot(root, true).EnableMouse(false).Run(); err != nil {
return err
}
return nil
}
func (s *appState) push(name string, primitive tview.Primitive) {
s.pages.AddPage(name, primitive, true, true)
s.stack = append(s.stack, name)
s.pages.SwitchToPage(name)
if menu, ok := primitive.(*Menu); ok {
s.menus[name] = menu
}
}
func (s *appState) pop() {
if len(s.stack) == 0 {
return
}
last := s.stack[len(s.stack)-1]
s.pages.RemovePage(last)
s.stack = s.stack[:len(s.stack)-1]
if len(s.stack) == 0 {
s.app.Stop()
return
}
current := s.stack[len(s.stack)-1]
s.pages.SwitchToPage(current)
if menu, ok := s.menus[current]; ok {
s.refreshMenu(current, menu)
}
}
func (s *appState) mainMenu() tview.Primitive {
menu := NewMenu("Menu", nil)
refreshMainMenu(menu, s)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
case tcell.KeyEsc:
s.requestExit()
return nil
}
return event
})
return menu
}
func (s *appState) refreshMenu(name string, menu *Menu) {
switch name {
case "main":
refreshMainMenu(menu, s)
case "model":
refreshModelMenuFromState(menu, s)
case "channel":
refreshChannelMenuFromState(menu, s)
}
}
func (s *appState) countChannels() (enabled int, total int) {
c := s.config.Channels
entries := []bool{
c.Telegram.Enabled,
c.Discord.Enabled,
c.QQ.Enabled,
c.MaixCam.Enabled,
c.WhatsApp.Enabled,
c.Feishu.Enabled,
c.DingTalk.Enabled,
c.Slack.Enabled,
c.Matrix.Enabled,
c.LINE.Enabled,
c.OneBot.Enabled,
c.WeCom.Enabled,
c.WeComApp.Enabled,
}
total = len(entries)
for _, v := range entries {
if v {
enabled++
}
}
return enabled, total
}
func refreshMainMenuIfPresent(s *appState) {
if menu, ok := s.menus["main"]; ok {
refreshMainMenu(menu, s)
}
}
func refreshMainMenu(menu *Menu, s *appState) {
selectedModel := s.selectedModelName()
modelReady := selectedModel != ""
channelReady := s.hasEnabledChannel()
enabledCount, totalChannels := s.countChannels()
gatewayRunning := s.gatewayCmd != nil || s.isGatewayRunning()
gatewayLabel := "Start Gateway"
gatewayDescription := "Launch gateway for channels"
if gatewayRunning {
gatewayLabel = "Stop Gateway"
gatewayDescription = "Gateway running"
}
items := []MenuItem{
{
Label: rootModelLabel(selectedModel),
Description: rootModelDescription(),
Action: func() {
s.push("model", s.modelMenu())
},
MainColor: func() *tcell.Color {
if modelReady {
return nil
}
color := tcell.ColorGray
return &color
}(),
},
{
Label: rootChannelLabel(channelReady),
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
Action: func() {
s.push("channel", s.channelMenu())
},
MainColor: func() *tcell.Color {
if channelReady {
return nil
}
color := tcell.ColorGray
return &color
}(),
},
{
Label: "Start Talk",
Description: "Open picoclaw agent in terminal",
Action: func() {
s.requestStartTalk()
},
Disabled: !modelReady,
},
{
Label: gatewayLabel,
Description: gatewayDescription,
Action: func() {
if gatewayRunning {
s.stopGateway()
} else {
s.requestStartGateway()
}
refreshMainMenu(menu, s)
},
Disabled: !gatewayRunning && (!modelReady || !channelReady),
},
{
Label: "View Gateway Log",
Description: "Open gateway.log",
Action: func() {
s.viewGatewayLog()
},
},
{
Label: "Exit",
Description: "Exit the TUI",
Action: func() {
s.requestExit()
},
},
}
menu.applyItems(items)
}
func (s *appState) applyChangesValidated() bool {
if err := s.config.ValidateModelList(); err != nil {
s.showMessage("Validation failed", err.Error())
return false
}
if err := s.validateAgentModel(); err != nil {
s.showMessage("Validation failed", err.Error())
return false
}
if err := configstore.Save(s.config); err != nil {
s.showMessage("Save failed", err.Error())
return false
}
if data, err := os.ReadFile(s.configPath); err == nil {
s.original = data
s.hasOriginal = true
_ = writeBackupConfig(s.backupPath, data)
}
return true
}
func (s *appState) requestExit() {
if s.dirty {
s.confirmApplyOrDiscard(func() {
s.app.Stop()
}, func() {
s.discardChanges()
s.app.Stop()
})
return
}
s.app.Stop()
}
func (s *appState) requestStartTalk() {
if s.dirty {
s.confirmApplyOrDiscard(func() {
s.startTalk()
}, func() {
s.startTalk()
})
return
}
s.startTalk()
}
func (s *appState) requestStartGateway() {
if s.dirty {
s.confirmApplyOrDiscard(func() {
s.startGateway()
}, func() {
s.startGateway()
})
return
}
s.startGateway()
}
func (s *appState) viewGatewayLog() {
data, err := os.ReadFile(s.logPath)
if err != nil {
s.showMessage("Log not found", "gateway.log not found")
return
}
text := tview.NewTextView()
text.SetBorder(true).SetTitle("Gateway Log")
text.SetText(string(data))
text.SetDoneFunc(func(key tcell.Key) {
s.pages.RemovePage("log")
})
text.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pages.RemovePage("log")
return nil
}
return event
})
s.pages.AddPage("log", text, true, true)
}
func (s *appState) selectedModelName() string {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
if modelName == "" {
return ""
}
if !s.isActiveModelValid() {
return ""
}
return modelName
}
func rootModelLabel(selected string) string {
if selected == "" {
return "Model (None)"
}
return "Model (" + selected + ")"
}
func rootModelDescription() string {
return "Using SPACE to choose your model"
}
func rootChannelLabel(valid bool) string {
if !valid {
return "Channel (no channel enabled)"
}
return "Channel"
}
func (s *appState) startTalk() {
if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting talk")
return
}
if !s.applyChangesValidated() {
return
}
s.app.Suspend(func() {
cmd := exec.Command("picoclaw", "agent")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
})
}
func (s *appState) startGateway() {
if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting gateway")
return
}
if !s.hasEnabledChannel() {
s.showMessage("Channel required", "Enable at least one channel before starting gateway")
return
}
if !s.applyChangesValidated() {
return
}
_ = stopGatewayProcess()
cmd := exec.Command("picoclaw", "gateway")
logFile, err := os.OpenFile(s.logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
s.showMessage("Gateway failed", err.Error())
return
}
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
s.showMessage("Gateway failed", err.Error())
_ = logFile.Close()
return
}
_ = logFile.Close()
s.gatewayCmd = cmd
}
func (s *appState) stopGateway() {
_ = stopGatewayProcess()
if s.gatewayCmd != nil && s.gatewayCmd.Process != nil {
_ = s.gatewayCmd.Process.Kill()
}
s.gatewayCmd = nil
}
func (s *appState) isGatewayRunning() bool {
return isGatewayProcessRunning()
}
func (s *appState) validateAgentModel() error {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
if modelName == "" {
return nil
}
_, err := s.config.GetModelConfig(modelName)
return err
}
func (s *appState) isActiveModelValid() bool {
modelName := strings.TrimSpace(s.config.Agents.Defaults.Model)
if modelName == "" {
return false
}
cfg, err := s.config.GetModelConfig(modelName)
if err != nil {
return false
}
hasKey := strings.TrimSpace(cfg.APIKey) != "" || strings.TrimSpace(cfg.AuthMethod) == "oauth"
hasModel := strings.TrimSpace(cfg.Model) != ""
return hasKey && hasModel
}
func (s *appState) hasEnabledChannel() bool {
c := s.config.Channels
return c.Telegram.Enabled || c.Discord.Enabled || c.QQ.Enabled || c.MaixCam.Enabled ||
c.WhatsApp.Enabled || c.Feishu.Enabled || c.DingTalk.Enabled || c.Slack.Enabled ||
c.Matrix.Enabled || c.LINE.Enabled || c.OneBot.Enabled || c.WeCom.Enabled || c.WeComApp.Enabled
}
func (s *appState) confirmApplyOrDiscard(onApply func(), onDiscard func()) {
if s.pages.HasPage("apply") {
return
}
modal := tview.NewModal().
SetText("Apply changes or discard before continuing?").
AddButtons([]string{"Cancel", "Discard", "Apply"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
s.pages.RemovePage("apply")
switch buttonLabel {
case "Discard":
s.discardChanges()
if onDiscard != nil {
onDiscard()
}
case "Apply":
if s.applyChangesValidated() {
s.dirty = false
if onApply != nil {
onApply()
}
}
}
})
modal.SetBorder(true)
s.pages.AddPage("apply", modal, true, true)
}
func (s *appState) discardChanges() {
if s.hasOriginal {
_ = writeOriginalConfig(s.configPath, s.original)
} else {
_ = os.Remove(s.configPath)
}
_ = os.Remove(s.backupPath)
if cfg, err := configstore.Load(); err == nil && cfg != nil {
s.config = cfg
}
s.dirty = false
refreshMainMenuIfPresent(s)
}
func (s *appState) showMessage(title, message string) {
if s.pages.HasPage("message") {
return
}
modal := tview.NewModal().
SetText(strings.TrimSpace(message)).
AddButtons([]string{"OK"}).
SetDoneFunc(func(_ int, _ string) {
s.pages.RemovePage("message")
})
modal.SetTitle(title).SetBorder(true)
modal.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
modal.SetTextColor(tview.Styles.PrimaryTextColor)
modal.SetButtonBackgroundColor(tcell.NewRGBColor(112, 102, 255))
modal.SetButtonTextColor(tview.Styles.PrimaryTextColor)
s.pages.AddPage("message", modal, true, true)
}
func loadOriginalConfig(path string) ([]byte, bool) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, false
}
return nil, false
}
return data, true
}
func writeOriginalConfig(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}
func writeBackupConfig(path string, data []byte) error {
return os.WriteFile(path, data, 0o600)
}

View file

@ -1,433 +0,0 @@
package ui
import (
"fmt"
"strings"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
func (s *appState) buildChannelMenuItems() []MenuItem {
return []MenuItem{
channelItem(
"Telegram",
"Telegram bot settings",
s.config.Channels.Telegram.Enabled,
func() { s.push("channel-telegram", s.telegramForm()) },
),
channelItem(
"Discord",
"Discord bot settings",
s.config.Channels.Discord.Enabled,
func() { s.push("channel-discord", s.discordForm()) },
),
channelItem(
"QQ",
"QQ bot settings",
s.config.Channels.QQ.Enabled,
func() { s.push("channel-qq", s.qqForm()) },
),
channelItem(
"MaixCam",
"MaixCam gateway",
s.config.Channels.MaixCam.Enabled,
func() { s.push("channel-maixcam", s.maixcamForm()) },
),
channelItem(
"WhatsApp",
"WhatsApp bridge",
s.config.Channels.WhatsApp.Enabled,
func() { s.push("channel-whatsapp", s.whatsappForm()) },
),
channelItem(
"Feishu",
"Feishu bot settings",
s.config.Channels.Feishu.Enabled,
func() { s.push("channel-feishu", s.feishuForm()) },
),
channelItem(
"DingTalk",
"DingTalk bot settings",
s.config.Channels.DingTalk.Enabled,
func() { s.push("channel-dingtalk", s.dingtalkForm()) },
),
channelItem(
"Slack",
"Slack bot settings",
s.config.Channels.Slack.Enabled,
func() { s.push("channel-slack", s.slackForm()) },
),
channelItem(
"Matrix",
"Matrix bot settings",
s.config.Channels.Matrix.Enabled,
func() { s.push("channel-matrix", s.matrixForm()) },
),
channelItem(
"LINE",
"LINE bot settings",
s.config.Channels.LINE.Enabled,
func() { s.push("channel-line", s.lineForm()) },
),
channelItem(
"OneBot",
"OneBot settings",
s.config.Channels.OneBot.Enabled,
func() { s.push("channel-onebot", s.onebotForm()) },
),
channelItem(
"WeCom",
"WeCom bot settings",
s.config.Channels.WeCom.Enabled,
func() { s.push("channel-wecom", s.wecomForm()) },
),
channelItem(
"WeCom App",
"WeCom App settings",
s.config.Channels.WeComApp.Enabled,
func() { s.push("channel-wecomapp", s.wecomAppForm()) },
),
}
}
func (s *appState) channelMenu() tview.Primitive {
menu := NewMenu("Channels", s.buildChannelMenuItems())
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return menu
}
func refreshChannelMenuFromState(menu *Menu, s *appState) {
menu.applyItems(s.buildChannelMenuItems())
}
func (s *appState) telegramForm() tview.Primitive {
cfg := &s.config.Channels.Telegram
form := baseChannelForm("Telegram", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Proxy", cfg.Proxy, 128, nil, func(text string) {
cfg.Proxy = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) discordForm() tview.Primitive {
cfg := &s.config.Channels.Discord
form := baseChannelForm("Discord", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddCheckbox("Mention Only", cfg.MentionOnly, func(checked bool) {
cfg.MentionOnly = checked
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) qqForm() tview.Primitive {
cfg := &s.config.Channels.QQ
form := baseChannelForm("QQ", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
cfg.AppID = strings.TrimSpace(text)
})
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
cfg.AppSecret = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) maixcamForm() tview.Primitive {
cfg := &s.config.Channels.MaixCam
form := baseChannelForm("MaixCam", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Host", cfg.Host, 64, nil, func(text string) {
cfg.Host = strings.TrimSpace(text)
})
addIntField(form, "Port", cfg.Port, func(value int) { cfg.Port = value })
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) whatsappForm() tview.Primitive {
cfg := &s.config.Channels.WhatsApp
form := baseChannelForm("WhatsApp", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Bridge URL", cfg.BridgeURL, 128, nil, func(text string) {
cfg.BridgeURL = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) feishuForm() tview.Primitive {
cfg := &s.config.Channels.Feishu
form := baseChannelForm("Feishu", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("App ID", cfg.AppID, 64, nil, func(text string) {
cfg.AppID = strings.TrimSpace(text)
})
form.AddInputField("App Secret", cfg.AppSecret, 128, nil, func(text string) {
cfg.AppSecret = strings.TrimSpace(text)
})
form.AddInputField("Encrypt Key", cfg.EncryptKey, 128, nil, func(text string) {
cfg.EncryptKey = strings.TrimSpace(text)
})
form.AddInputField("Verification Token", cfg.VerificationToken, 128, nil, func(text string) {
cfg.VerificationToken = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) dingtalkForm() tview.Primitive {
cfg := &s.config.Channels.DingTalk
form := baseChannelForm("DingTalk", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Client ID", cfg.ClientID, 64, nil, func(text string) {
cfg.ClientID = strings.TrimSpace(text)
})
form.AddInputField("Client Secret", cfg.ClientSecret, 128, nil, func(text string) {
cfg.ClientSecret = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) slackForm() tview.Primitive {
cfg := &s.config.Channels.Slack
form := baseChannelForm("Slack", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Bot Token", cfg.BotToken, 128, nil, func(text string) {
cfg.BotToken = strings.TrimSpace(text)
})
form.AddInputField("App Token", cfg.AppToken, 128, nil, func(text string) {
cfg.AppToken = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) lineForm() tview.Primitive {
cfg := &s.config.Channels.LINE
form := baseChannelForm("LINE", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Channel Secret", cfg.ChannelSecret, 128, nil, func(text string) {
cfg.ChannelSecret = strings.TrimSpace(text)
})
form.AddInputField("Channel Access Token", cfg.ChannelAccessToken, 128, nil, func(text string) {
cfg.ChannelAccessToken = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) matrixForm() tview.Primitive {
cfg := &s.config.Channels.Matrix
form := baseChannelForm("Matrix", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Homeserver", cfg.Homeserver, 128, nil, func(text string) {
cfg.Homeserver = strings.TrimSpace(text)
})
form.AddInputField("User ID", cfg.UserID, 128, nil, func(text string) {
cfg.UserID = strings.TrimSpace(text)
})
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
cfg.AccessToken = strings.TrimSpace(text)
})
form.AddInputField("Device ID", cfg.DeviceID, 128, nil, func(text string) {
cfg.DeviceID = strings.TrimSpace(text)
})
form.AddCheckbox("Join On Invite", cfg.JoinOnInvite, func(checked bool) {
cfg.JoinOnInvite = checked
})
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) onebotForm() tview.Primitive {
cfg := &s.config.Channels.OneBot
form := baseChannelForm("OneBot", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("WS URL", cfg.WSUrl, 128, nil, func(text string) {
cfg.WSUrl = strings.TrimSpace(text)
})
form.AddInputField("Access Token", cfg.AccessToken, 128, nil, func(text string) {
cfg.AccessToken = strings.TrimSpace(text)
})
addIntField(
form,
"Reconnect Interval",
cfg.ReconnectInterval,
func(value int) { cfg.ReconnectInterval = value },
)
form.AddInputField(
"Group Trigger Prefix",
strings.Join(cfg.GroupTriggerPrefix, ","),
128,
nil,
func(text string) {
cfg.GroupTriggerPrefix = splitCSV(text)
},
)
addAllowFromField(form, &cfg.AllowFrom)
return wrapWithBack(form, s)
}
func (s *appState) wecomForm() tview.Primitive {
cfg := &s.config.Channels.WeCom
form := baseChannelForm("WeCom", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
cfg.EncodingAESKey = strings.TrimSpace(text)
})
form.AddInputField("Webhook URL", cfg.WebhookURL, 128, nil, func(text string) {
cfg.WebhookURL = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
cfg.ReplyTimeout,
func(value int) { cfg.ReplyTimeout = value },
)
return wrapWithBack(form, s)
}
func (s *appState) wecomAppForm() tview.Primitive {
cfg := &s.config.Channels.WeComApp
form := baseChannelForm("WeCom App", cfg.Enabled, s.makeChannelOnEnabled(&cfg.Enabled))
form.AddInputField("Corp ID", cfg.CorpID, 64, nil, func(text string) {
cfg.CorpID = strings.TrimSpace(text)
})
form.AddInputField("Corp Secret", cfg.CorpSecret, 128, nil, func(text string) {
cfg.CorpSecret = strings.TrimSpace(text)
})
addInt64Field(form, "Agent ID", cfg.AgentID, func(value int64) { cfg.AgentID = value })
form.AddInputField("Token", cfg.Token, 128, nil, func(text string) {
cfg.Token = strings.TrimSpace(text)
})
form.AddInputField("Encoding AES Key", cfg.EncodingAESKey, 128, nil, func(text string) {
cfg.EncodingAESKey = strings.TrimSpace(text)
})
form.AddInputField("Webhook Host", cfg.WebhookHost, 64, nil, func(text string) {
cfg.WebhookHost = strings.TrimSpace(text)
})
addIntField(form, "Webhook Port", cfg.WebhookPort, func(value int) { cfg.WebhookPort = value })
form.AddInputField("Webhook Path", cfg.WebhookPath, 64, nil, func(text string) {
cfg.WebhookPath = strings.TrimSpace(text)
})
addAllowFromField(form, &cfg.AllowFrom)
addIntField(
form,
"Reply Timeout",
cfg.ReplyTimeout,
func(value int) { cfg.ReplyTimeout = value },
)
return wrapWithBack(form, s)
}
func (s *appState) makeChannelOnEnabled(enabledPtr *bool) func(bool) {
return func(v bool) {
*enabledPtr = v
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["channel"]; ok {
refreshChannelMenuFromState(menu, s)
}
}
}
func addAllowFromField(form *tview.Form, allowFrom *picoclawconfig.FlexibleStringSlice) {
form.AddInputField("Allow From", strings.Join(*allowFrom, ","), 128, nil, func(text string) {
*allowFrom = splitCSV(text)
})
}
func baseChannelForm(title string, enabled bool, onEnabled func(bool)) *tview.Form {
form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Channel: %s", title))
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
form.AddCheckbox("Enabled", enabled, func(checked bool) {
onEnabled(checked)
})
return form
}
func wrapWithBack(form *tview.Form, s *appState) tview.Primitive {
form.AddButton("Back", func() {
s.pop()
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return form
}
func splitCSV(input string) picoclawconfig.FlexibleStringSlice {
parts := strings.Split(strings.TrimSpace(input), ",")
cleaned := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
cleaned = append(cleaned, value)
}
return cleaned
}
func addIntField(form *tview.Form, label string, value int, onChange func(int)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func addInt64Field(form *tview.Form, label string, value int64, onChange func(int64)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int64
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func channelItem(label, description string, enabled bool, action MenuAction) MenuItem {
item := MenuItem{
Label: label,
Description: description,
Action: action,
}
if !enabled {
color := tcell.ColorGray
item.MainColor = &color
}
return item
}

View file

@ -1,16 +0,0 @@
//go:build !windows
// +build !windows
package ui
import "os/exec"
func isGatewayProcessRunning() bool {
cmd := exec.Command("sh", "-c", "pgrep -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
return cmd.Run() == nil
}
func stopGatewayProcess() error {
cmd := exec.Command("sh", "-c", "pkill -f 'picoclaw\\s+gateway' >/dev/null 2>&1")
return cmd.Run()
}

View file

@ -1,16 +0,0 @@
//go:build windows
// +build windows
package ui
import "os/exec"
func isGatewayProcessRunning() bool {
cmd := exec.Command("tasklist", "/FI", "IMAGENAME eq picoclaw.exe")
return cmd.Run() == nil
}
func stopGatewayProcess() error {
cmd := exec.Command("taskkill", "/F", "/IM", "picoclaw.exe")
return cmd.Run()
}

View file

@ -1,72 +0,0 @@
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
type MenuAction func()
type MenuItem struct {
Label string
Description string
Action MenuAction
Disabled bool
MainColor *tcell.Color
DescColor *tcell.Color
}
type Menu struct {
*tview.Table
items []MenuItem
}
func NewMenu(title string, items []MenuItem) *Menu {
table := tview.NewTable().SetSelectable(true, false)
table.SetBorder(true).SetTitle(title)
table.SetBorders(false)
menu := &Menu{Table: table, items: items}
menu.applyItems(items)
menu.SetSelectedFunc(func(row, _ int) {
if row < 0 || row >= len(menu.items) {
return
}
item := menu.items[row]
if item.Disabled || item.Action == nil {
return
}
item.Action()
})
menu.SetSelectedStyle(
tcell.StyleDefault.Foreground(tview.Styles.InverseTextColor).
Background(tcell.NewRGBColor(189, 147, 249)),
)
return menu
}
func (m *Menu) applyItems(items []MenuItem) {
m.items = items
m.Clear()
for row, item := range items {
label := item.Label
if item.Disabled && label != "" {
label = label + " (disabled)"
}
left := tview.NewTableCell(label)
right := tview.NewTableCell(item.Description).SetAlign(tview.AlignRight)
if item.MainColor != nil {
left.SetTextColor(*item.MainColor)
}
if item.DescColor != nil {
right.SetTextColor(*item.DescColor)
} else {
right.SetTextColor(tview.Styles.TertiaryTextColor)
}
if item.Disabled {
left.SetTextColor(tcell.ColorGray)
right.SetTextColor(tcell.ColorGray)
}
m.SetCell(row, 0, left)
m.SetCell(row, 1, right)
}
}

View file

@ -1,399 +0,0 @@
package ui
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
picoclawconfig "github.com/sipeed/picoclaw/pkg/config"
)
func (s *appState) modelMenu() tview.Primitive {
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList {
index := i
model := s.config.ModelList[i]
isValid := isModelValid(model)
desc := model.APIBase
if desc == "" {
desc = model.AuthMethod
}
if desc == "" {
desc = "api_key required"
}
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
isSelected := model.ModelName == currentModel && currentModel != ""
items = append(items, MenuItem{
Label: label,
Description: desc,
MainColor: modelStatusColor(isValid, isSelected),
Action: func() {
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
},
})
}
// Add model entry appended at the end so the models map to rows 1..N
items = append(items,
MenuItem{
Label: "**Add model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
s.modelForm(len(s.config.ModelList)-1),
)
},
},
)
menu := NewMenu("Models", items)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
if event.Rune() == ' ' {
row, _ := menu.GetSelection()
if row >= 0 && row < len(s.config.ModelList) {
model := s.config.ModelList[row]
if !isModelValid(model) {
s.showMessage(
"Invalid model",
"Select a model with api_key or oauth auth_method",
)
return nil
}
s.config.Agents.Defaults.Model = model.ModelName
s.dirty = true
refreshModelMenu(menu, s.config.Agents.Defaults.Model, s.config.ModelList)
refreshMainMenuIfPresent(s)
}
return nil
}
return event
})
return menu
}
func (s *appState) modelForm(index int) tview.Primitive {
model := &s.config.ModelList[index]
form := tview.NewForm()
form.SetBorder(true).SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
addInput(form, "Model Name", model.ModelName, func(value string) {
if value == "" {
s.showMessage("Invalid model name", "Model Name cannot be empty")
return
}
if s.modelNameExists(value, index) {
s.showMessage("Duplicate model name", fmt.Sprintf("Model Name '%s' already exists", value))
return
}
oldName := model.ModelName
model.ModelName = value
if s.config.Agents.Defaults.Model == oldName {
s.config.Agents.Defaults.Model = value
}
s.dirty = true
form.SetTitle(fmt.Sprintf("Model: %s", model.ModelName))
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Model", model.Model, func(value string) {
model.Model = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "API Base", model.APIBase, func(value string) {
model.APIBase = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "API Key", model.APIKey, func(value string) {
model.APIKey = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Proxy", model.Proxy, func(value string) {
model.Proxy = value
})
addInput(form, "Auth Method", model.AuthMethod, func(value string) {
model.AuthMethod = value
s.dirty = true
refreshMainMenuIfPresent(s)
if menu, ok := s.menus["model"]; ok {
refreshModelMenuFromState(menu, s)
}
})
addInput(form, "Connect Mode", model.ConnectMode, func(value string) {
model.ConnectMode = value
})
addInput(form, "Workspace", model.Workspace, func(value string) {
model.Workspace = value
})
addInput(form, "Max Tokens Field", model.MaxTokensField, func(value string) {
model.MaxTokensField = value
})
addIntInput(form, "RPM", model.RPM, func(value int) {
model.RPM = value
})
addIntInput(form, "Request Timeout", model.RequestTimeout, func(value int) {
model.RequestTimeout = value
})
form.AddButton("Delete", func() {
pageName := "confirm-delete-model"
if s.pages.HasPage(pageName) {
return
}
modal := tview.NewModal().
SetText("Are you sure you want to delete this model?").
AddButtons([]string{"Cancel", "Delete"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
s.pages.RemovePage(pageName)
if buttonLabel == "Delete" {
s.deleteModel(index)
}
})
modal.SetTitle("Confirm Delete").SetBorder(true)
s.pages.AddPage(pageName, modal, true, true)
})
form.AddButton("Test", func() {
s.testModel(model)
})
form.AddButton("Back", func() {
s.pop()
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEsc {
s.pop()
return nil
}
return event
})
return form
}
func addInput(form *tview.Form, label, value string, onChange func(string)) {
form.AddInputField(label, value, 128, nil, func(text string) {
onChange(strings.TrimSpace(text))
})
}
func addIntInput(form *tview.Form, label string, value int, onChange func(int)) {
form.AddInputField(label, fmt.Sprintf("%d", value), 16, nil, func(text string) {
var parsed int
if _, err := fmt.Sscanf(strings.TrimSpace(text), "%d", &parsed); err == nil {
onChange(parsed)
}
})
}
func (s *appState) addModel(model picoclawconfig.ModelConfig) {
s.config.ModelList = append(s.config.ModelList, model)
}
func (s *appState) deleteModel(index int) {
if index < 0 || index >= len(s.config.ModelList) {
return
}
s.config.ModelList = append(s.config.ModelList[:index], s.config.ModelList[index+1:]...)
s.pop()
}
func modelStatusColor(valid bool, selected bool) *tcell.Color {
if valid {
color := tview.Styles.PrimaryTextColor
return &color
}
color := tcell.ColorGray
return &color
}
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
for i, model := range models {
row := i
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
isValid := isModelValid(model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
cell := menu.GetCell(row, 0)
if cell != nil {
cell.SetText(label)
isSelected := model.ModelName == currentModel && currentModel != ""
color := modelStatusColor(isValid, isSelected)
if color != nil {
cell.SetTextColor(*color)
}
}
}
}
func refreshModelMenuFromState(menu *Menu, s *appState) {
items := make([]MenuItem, 0, 1+len(s.config.ModelList))
currentModel := strings.TrimSpace(s.config.Agents.Defaults.Model)
for i := range s.config.ModelList {
index := i
model := s.config.ModelList[i]
isValid := isModelValid(model)
desc := model.APIBase
if desc == "" {
desc = model.AuthMethod
}
if desc == "" {
desc = "api_key required"
}
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
if model.ModelName == currentModel && currentModel != "" {
label = "* " + label
}
isSelected := model.ModelName == currentModel && currentModel != ""
items = append(items, MenuItem{
Label: label,
Description: desc,
MainColor: modelStatusColor(isValid, isSelected),
Action: func() {
s.push(fmt.Sprintf("model-%d", index), s.modelForm(index))
},
})
}
items = append(items,
MenuItem{
Label: "**Add Model**",
Description: "Append a new model entry",
Action: func() {
newName := s.nextAvailableModelName("new-model")
s.addModel(
picoclawconfig.ModelConfig{ModelName: newName, Model: "openai/gpt-5.2"},
)
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
},
},
)
menu.applyItems(items)
}
func isModelValid(model picoclawconfig.ModelConfig) bool {
hasKey := strings.TrimSpace(model.APIKey) != "" ||
strings.TrimSpace(model.AuthMethod) == "oauth"
hasModel := strings.TrimSpace(model.Model) != ""
return hasKey && hasModel
}
func (s *appState) modelNameExists(name string, excludeIndex int) bool {
target := strings.TrimSpace(name)
if target == "" {
return false
}
for i := range s.config.ModelList {
if i == excludeIndex {
continue
}
if strings.TrimSpace(s.config.ModelList[i].ModelName) == target {
return true
}
}
return false
}
func (s *appState) nextAvailableModelName(base string) string {
name := strings.TrimSpace(base)
if name == "" {
name = "new-model"
}
if !s.modelNameExists(name, -1) {
return name
}
for i := 2; ; i++ {
candidate := fmt.Sprintf("%s-%d", name, i)
if !s.modelNameExists(candidate, -1) {
return candidate
}
}
}
func (s *appState) testModel(model *picoclawconfig.ModelConfig) {
if model == nil {
return
}
if strings.TrimSpace(model.APIKey) == "" {
s.showMessage("Missing API Key", "Set api_key before testing")
return
}
base := strings.TrimSpace(model.APIBase)
if base == "" {
s.showMessage("Missing API Base", "Set api_base before testing")
return
}
modelID := strings.TrimSpace(model.Model)
if modelID == "" {
s.showMessage("Missing Model", "Set model before testing")
return
}
if !strings.HasPrefix(modelID, "openai/") {
s.showMessage("Unsupported model", "Only openai/* models are supported for test")
return
}
modelName := strings.TrimPrefix(modelID, "openai/")
endpoint := strings.TrimRight(base, "/") + "/chat/completions"
payload := fmt.Sprintf(
`{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}`,
modelName,
)
client := &http.Client{Timeout: 10 * time.Second}
request, err := http.NewRequest("POST", endpoint, strings.NewReader(payload))
if err != nil {
s.showMessage("Test failed", err.Error())
return
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+strings.TrimSpace(model.APIKey))
resp, err := client.Do(request)
if err != nil {
s.showMessage("Test failed", err.Error())
return
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
s.showMessage("Test OK", resp.Status)
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
if err != nil {
s.showMessage("Test failed", fmt.Sprintf("failed to read response: %v", err))
return
}
s.showMessage(
"Test failed",
fmt.Sprintf("%s: %s", resp.Status, strings.TrimSpace(string(body))),
)
}

View file

@ -1,55 +0,0 @@
package ui
import (
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const (
colorBlue = "[#3e5db9]"
colorRed = "[#d54646]"
banner = "\r\n[::b]" +
colorBlue + "██████╗ ██╗ ██████╗ ██████╗ " + colorRed + " ██████╗██╗ █████╗ ██╗ ██╗\n" +
colorBlue + "██╔══██╗██║██╔════╝██╔═══██╗" + colorRed + "██╔════╝██║ ██╔══██╗██║ ██║\n" +
colorBlue + "██████╔╝██║██║ ██║ ██║" + colorRed + "██║ ██║ ███████║██║ █╗ ██║\n" +
colorBlue + "██╔═══╝ ██║██║ ██║ ██║" + colorRed + "██║ ██║ ██╔══██║██║███╗██║\n" +
colorBlue + "██║ ██║╚██████╗╚██████╔╝" + colorRed + "╚██████╗███████╗██║ ██║╚███╔███╔╝\n" +
colorBlue + "╚═╝ ╚═╝ ╚═════╝ ╚═════╝ " + colorRed + " ╚═════╝╚══════╝╚═╝ ╚═╝ ╚══╝╚══╝\n " +
"[:]"
)
func applyStyles() {
tview.Styles.PrimitiveBackgroundColor = tcell.NewRGBColor(12, 13, 22)
tview.Styles.ContrastBackgroundColor = tcell.NewRGBColor(34, 19, 53)
tview.Styles.MoreContrastBackgroundColor = tcell.NewRGBColor(18, 18, 32)
tview.Styles.BorderColor = tcell.NewRGBColor(112, 102, 255)
tview.Styles.TitleColor = tcell.NewRGBColor(255, 121, 198)
tview.Styles.GraphicsColor = tcell.NewRGBColor(139, 233, 253)
tview.Styles.PrimaryTextColor = tcell.NewRGBColor(241, 250, 255)
tview.Styles.SecondaryTextColor = tcell.NewRGBColor(80, 250, 123)
tview.Styles.TertiaryTextColor = tcell.NewRGBColor(139, 233, 253)
tview.Styles.InverseTextColor = tcell.NewRGBColor(12, 13, 22)
tview.Styles.ContrastSecondaryTextColor = tcell.NewRGBColor(189, 147, 249)
}
func bannerView() *tview.TextView {
text := tview.NewTextView()
text.SetDynamicColors(true)
text.SetTextAlign(tview.AlignCenter)
text.SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
text.SetText(banner)
text.SetBorder(false)
return text
}
const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
func footerView() *tview.TextView {
text := tview.NewTextView()
text.SetTextAlign(tview.AlignCenter)
text.SetText(footerText)
text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
text.SetTextColor(tview.Styles.PrimaryTextColor)
text.SetBorder(false)
return text
}

View file

@ -1,15 +1,48 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/internal/ui"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
"github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/ui"
)
func main() {
if err := ui.Run(); err != nil {
fmt.Fprintln(os.Stderr, err)
configPath := tuicfg.DefaultConfigPath()
if len(os.Args) > 1 {
configPath = os.Args[1]
}
configDir := filepath.Dir(configPath)
if _, err := os.Stat(configDir); os.IsNotExist(err) {
cmd := exec.Command("picoclaw", "onboard")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
}
cfg, err := tuicfg.Load(configPath)
if err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
app := ui.New(cfg, configPath)
// Bind model selection hook to sync to main config
app.OnModelSelected = func(scheme tuicfg.Scheme, user tuicfg.User, modelID string) {
_ = tuicfg.SyncSelectedModelToMainConfig(scheme, user, modelID)
}
if err := app.Run(); err != nil {
fmt.Fprintf(os.Stderr, "picoclaw-launcher-tui: %v\n", err)
os.Exit(1)
}
}

View file

@ -0,0 +1,325 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"sync"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
// App is the root TUI application.
type App struct {
tapp *tview.Application
pages *tview.Pages
pageStack []string
cfg *tuicfg.TUIConfig
configPath string
pageRefreshFns map[string]func()
headerModelTV *tview.TextView
modalOpen map[string]bool
// OnModelSelected is called when a model is selected in the UI.
// Can be nil to disable.
OnModelSelected func(scheme tuicfg.Scheme, user tuicfg.User, modelID string)
modelCache map[string][]modelEntry
modelCacheMu sync.RWMutex
refreshMu sync.Mutex
}
// cacheKey returns the map key for a (scheme, user) pair.
func cacheKey(schemeName, userName string) string {
return fmt.Sprintf("%s/%s", schemeName, userName)
}
// cachedModels returns a defensive copy of the cached model list for a user (may be nil).
func (a *App) cachedModels(schemeName, userName string) []modelEntry {
a.modelCacheMu.RLock()
defer a.modelCacheMu.RUnlock()
entries := a.modelCache[cacheKey(schemeName, userName)]
return append([]modelEntry(nil), entries...)
}
// refreshModelCache fetches models for every user in the config concurrently.
// Serialized by refreshMu so concurrent calls don't race on the cache map.
// When all fetches complete it calls onDone via QueueUpdateDraw.
func (a *App) refreshModelCache(onDone func()) {
go func() {
a.refreshMu.Lock()
defer a.refreshMu.Unlock()
users := a.cfg.Provider.Users
schemes := a.cfg.Provider.Schemes
schemeURL := make(map[string]string, len(schemes))
for _, s := range schemes {
schemeURL[s.Name] = s.BaseURL
}
var wg sync.WaitGroup
for _, u := range users {
baseURL, ok := schemeURL[u.Scheme]
if !ok || baseURL == "" {
continue
}
if u.Key == "" {
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
a.modelCacheMu.Unlock()
continue
}
wg.Add(1)
bURL := baseURL
go func() {
defer wg.Done()
entries, err := fetchModels(bURL, u.Key)
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err != nil || len(entries) == 0 {
a.modelCache[cacheKey(u.Scheme, u.Name)] = nil
} else {
a.modelCache[cacheKey(u.Scheme, u.Name)] = entries
}
a.modelCacheMu.Unlock()
}()
}
wg.Wait()
if onDone != nil {
a.tapp.QueueUpdateDraw(onDone)
}
}()
}
// New creates and wires up the TUI application.
func New(cfg *tuicfg.TUIConfig, configPath string) *App {
// Cyberpunk Theme Colors
// Dark background
tview.Styles.PrimitiveBackgroundColor = tcell.NewHexColor(0x050510) // Deep Void
tview.Styles.ContrastBackgroundColor = tcell.NewHexColor(0x1a1a2e) // Dark Indigo
tview.Styles.MoreContrastBackgroundColor = tcell.NewHexColor(0x2a2a40)
// Borders and Titles
tview.Styles.BorderColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TitleColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.GraphicsColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
// Text
tview.Styles.PrimaryTextColor = tcell.NewHexColor(0xe0e0e0) // Off-white
tview.Styles.SecondaryTextColor = tcell.NewHexColor(0x00f0ff) // Neon Cyan
tview.Styles.TertiaryTextColor = tcell.NewHexColor(0x39ff14) // Neon Lime
tview.Styles.InverseTextColor = tcell.NewHexColor(0x000000) // Black
tview.Styles.ContrastSecondaryTextColor = tcell.NewHexColor(0xff00ff) // Neon Magenta
a := &App{
tapp: tview.NewApplication(),
pages: tview.NewPages(),
pageStack: []string{},
cfg: cfg,
configPath: configPath,
pageRefreshFns: make(map[string]func()),
modalOpen: make(map[string]bool),
}
a.tapp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
if len(a.modalOpen) > 0 {
return event
}
return a.goBack()
}
return event
})
a.buildPages()
return a
}
// Run starts the TUI event loop.
func (a *App) Run() error {
return a.tapp.SetRoot(a.pages, true).EnableMouse(true).Run()
}
func (a *App) buildPages() {
a.pages.AddPage("home", a.newHomePage(), true, true)
a.pageStack = []string{"home"}
}
func (a *App) navigateTo(name string, page tview.Primitive) {
a.pages.RemovePage(name)
a.pages.AddPage(name, page, true, false)
a.pageStack = append(a.pageStack, name)
a.pages.SwitchToPage(name)
}
func (a *App) goBack() *tcell.EventKey {
if len(a.pageStack) <= 1 {
return nil
}
popped := a.pageStack[len(a.pageStack)-1]
a.pageStack = a.pageStack[:len(a.pageStack)-1]
a.pages.RemovePage(popped)
prev := a.pageStack[len(a.pageStack)-1]
if fn, ok := a.pageRefreshFns[prev]; ok {
fn()
}
if prev == "home" && a.headerModelTV != nil {
a.headerModelTV.SetText(a.cfg.CurrentModelLabel() + " ")
}
a.pages.SwitchToPage(prev)
return nil
}
func (a *App) showModal(name string, primitive tview.Primitive) {
a.modalOpen[name] = true
a.pages.AddPage(name, primitive, true, true)
}
func (a *App) hideModal(name string) {
delete(a.modalOpen, name)
a.pages.HidePage(name)
a.pages.RemovePage(name)
}
func (a *App) save() {
if err := tuicfg.Save(a.configPath, a.cfg); err != nil {
a.showError("save failed: " + err.Error())
}
}
func (a *App) showError(msg string) {
modal := tview.NewModal().
SetText(" [red::b]ERROR[-::-]\n\n" + msg).
AddButtons([]string{"OK"}).
SetDoneFunc(func(_ int, _ string) {
a.hideModal("error")
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("error", modal)
}
func (a *App) confirmDelete(label string, onConfirm func()) {
modal := tview.NewModal().
SetText(" [red::b]DELETE WARNING[-::-]\n\nDelete " + label + "?\n[gray]This action cannot be undone.[-]").
AddButtons([]string{"Delete", "Cancel"}).
SetDoneFunc(func(_ int, buttonLabel string) {
a.hideModal("confirm-delete")
if buttonLabel == "Delete" {
onConfirm()
}
})
// Cyberpunk Modal Style
modal.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
modal.SetTextColor(tcell.NewHexColor(0xffffff)) // White
modal.SetButtonBackgroundColor(tcell.NewHexColor(0xff2a2a)) // Neon Red for danger
modal.SetButtonTextColor(tcell.NewHexColor(0xffffff)) // White
a.showModal("confirm-delete", modal)
}
func centeredForm(form *tview.Form, widthPct, height int) tview.Primitive {
return tview.NewFlex().
AddItem(tview.NewBox(), 0, 1, false).
AddItem(tview.NewFlex().SetDirection(tview.FlexRow).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(form, height, 1, true).
AddItem(tview.NewBox(), 0, 1, false), 0, widthPct, true).
AddItem(tview.NewBox(), 0, 1, false)
}
func hintBar(text string) *tview.TextView {
tv := tview.NewTextView().
SetText(text).
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetTextColor(tcell.NewHexColor(0x00f0ff)) // Neon Cyan
tv.SetBackgroundColor(tcell.NewHexColor(0x2a2a40)) // Darker Indigo
return tv
}
func (a *App) buildShell(pageID string, content tview.Primitive, hint string) tview.Primitive {
var modelTV *tview.TextView
if pageID == "home" {
if a.headerModelTV == nil {
a.headerModelTV = tview.NewTextView()
a.headerModelTV.SetTextAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x39ff14)). // Neon Lime
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
}
modelTV = a.headerModelTV
modelTV.SetText("MODEL: " + a.cfg.CurrentModelLabel() + " ")
} else {
modelTV = tview.NewTextView()
modelTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
}
headerLeft := tview.NewTextView().
SetText(" [#ff00ff::b]///[#00f0ff] PICOCLAW LAUNCHER [#ff00ff]///").
SetDynamicColors(true).
SetBackgroundColor(tcell.NewHexColor(0x050510))
header := tview.NewFlex().
AddItem(headerLeft, 0, 1, false).
AddItem(modelTV, 0, 1, false)
sidebar := tview.NewTextView().
SetDynamicColors(true).
SetWrap(false)
sidebar.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e)) // Deep Indigo
// Cyberpunk Sidebar Styling
activePrefix := "[#39ff14::b]>> " // Neon Lime arrow
activeSuffix := "[-]"
inactivePrefix := "[#808080] "
inactiveSuffix := "[-]"
sbText := "\n\n" // Top padding
menuItem := func(id, label string) string {
if pageID == id {
return activePrefix + label + activeSuffix + "\n\n"
}
return inactivePrefix + label + inactiveSuffix + "\n\n"
}
sbText += menuItem("home", "HOME")
sbText += menuItem("schemes", "SCHEMES")
sbText += menuItem("users", "USERS")
sbText += menuItem("models", "MODELS")
sbText += menuItem("channels", "CHANNELS")
sbText += menuItem("gateway", "GATEWAY")
sidebar.SetText(sbText)
footer := hintBar(hint)
grid := tview.NewGrid().
SetRows(1, 0, 1).
SetColumns(20, 0). // Slightly wider sidebar
AddItem(header, 0, 0, 1, 2, 0, 0, false).
AddItem(sidebar, 1, 0, 1, 1, 0, 0, false).
AddItem(content, 1, 1, 1, 1, 0, 0, true).
AddItem(footer, 2, 0, 1, 2, 0, 0, false)
// Add a border around the content area if possible, or ensure content has its own border
// grid.SetBorders(false) // Grid borders usually look bad, handled by components
return grid
}

View file

@ -0,0 +1,202 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"strconv"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newChannelsPage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] COMMUNICATION CHANNELS ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuild := func() {
sel := list.GetCurrentItem()
list.Clear()
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
configPath := filepath.Join(home, ".picoclaw", "config.json")
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
_ = json.Unmarshal(data, &cfg)
}
if chRaw, ok := cfg["channels"].(map[string]any); ok {
for name, ch := range chRaw {
chMap, ok := ch.(map[string]any)
enabled := "disabled"
if ok {
if e, ok := chMap["enabled"].(bool); ok && e {
enabled = "enabled"
}
}
list.AddItem(name, fmt.Sprintf("Status: %s", enabled), 0, func() {
a.showChannelEditForm(configPath, name, chMap)
})
}
}
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuild()
a.pageRefreshFns["channels"] = rebuild
list.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
return a.goBack()
}
return event
})
return a.buildShell("channels", list, " [#ff00ff]Enter:[-] edit [#ff2a2a]ESC:[-] back ")
}
func (a *App) showChannelEditForm(configPath, channelName string, existing map[string]any) {
form := tview.NewForm()
form.SetBorder(true).
SetTitle(" [::b]EDIT CHANNEL ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
fields := make(map[string]*tview.InputField)
var nameField *tview.InputField
if channelName == "" {
nameField = tview.NewInputField().
SetLabel("Channel Name").
SetText("").
SetFieldWidth(28)
form.AddFormItem(nameField)
}
for k, v := range existing {
if reflect.ValueOf(v).Kind() == reflect.Map || reflect.ValueOf(v).Kind() == reflect.Slice {
continue
}
valStr := fmt.Sprintf("%v", v)
field := tview.NewInputField().
SetLabel(k).
SetText(valStr).
SetFieldWidth(28)
form.AddFormItem(field)
fields[k] = field
}
form.AddButton("SAVE", func() {
var cfg map[string]any
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, &cfg); err != nil {
cfg = make(map[string]any)
}
} else {
cfg = make(map[string]any)
}
if _, ok := cfg["channels"]; !ok {
cfg["channels"] = make(map[string]any)
}
channels, ok := cfg["channels"].(map[string]any)
if !ok {
channels = make(map[string]any)
cfg["channels"] = channels
}
finalName := channelName
if channelName == "" {
if nameField == nil || nameField.GetText() == "" {
a.showError("Channel name is required")
return
}
finalName = nameField.GetText()
}
updated := make(map[string]any)
if existing != nil {
for k, v := range existing {
updated[k] = v
}
}
for k, field := range fields {
val := field.GetText()
if val == "true" {
updated[k] = true
} else if val == "false" {
updated[k] = false
} else if num, err := strconv.Atoi(val); err == nil {
updated[k] = num
} else {
updated[k] = val
}
}
if channelName != "" && finalName != channelName {
delete(channels, channelName)
}
channels[finalName] = updated
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
a.showError(fmt.Sprintf("Failed to save config: %v", err))
return
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o700); err != nil {
a.showError(fmt.Sprintf("Failed to create config directory: %v", err))
return
}
if err := os.WriteFile(configPath, data, 0o600); err != nil {
a.showError(fmt.Sprintf("Failed to write config: %v", err))
return
}
a.hideModal("channel-edit")
a.goBack()
})
form.AddButton("CANCEL", func() {
a.hideModal("channel-edit")
})
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("channel-edit")
return nil
}
return event
})
a.showModal("channel-edit", centeredForm(form, 4, 20))
}

View file

@ -0,0 +1,229 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"os/exec"
"runtime"
"strconv"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"github.com/sipeed/picoclaw/pkg/config"
ppid "github.com/sipeed/picoclaw/pkg/pid"
)
type gatewayStatus struct {
running bool
pid int
version string
}
func picoHome() string {
return config.GetHome()
}
func getGatewayStatus() gatewayStatus {
data := ppid.ReadPidFileWithCheck(picoHome())
if data == nil {
return gatewayStatus{running: false}
}
return gatewayStatus{
running: true,
pid: data.PID,
version: data.Version,
}
}
func startGateway() error {
status := getGatewayStatus()
if status.running {
return fmt.Errorf("gateway is already running (PID: %d)", status.pid)
}
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
cmd = exec.Command("cmd", "/C", "start /B picoclaw gateway > NUL 2>&1")
} else {
cmd = exec.Command("sh", "-c", "nohup picoclaw gateway > /dev/null 2>&1 &")
}
err := cmd.Start()
if err != nil {
return err
}
time.Sleep(1 * time.Second)
if runtime.GOOS == "windows" {
cmd := exec.Command(
"wmic",
"process",
"where",
"name='picoclaw.exe' and commandline like '%gateway%'",
"get",
"processid",
)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("failed to get gateway PID: %w", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines[1:] {
line = strings.TrimSpace(line)
if line == "" {
continue
}
_, err := strconv.Atoi(line)
if err == nil {
break
}
}
}
status = getGatewayStatus()
if !status.running {
return fmt.Errorf("failed to start gateway")
}
return nil
}
func stopGateway() error {
status := getGatewayStatus()
if !status.running {
return fmt.Errorf("gateway is not running")
}
var err error
if runtime.GOOS == "windows" {
err = exec.Command("taskkill", "/F", "/PID", strconv.Itoa(status.pid)).Run()
} else {
err = exec.Command("kill", strconv.Itoa(status.pid)).Run()
}
if err != nil {
return err
}
// Wait for process to stop (ReadPidFileWithCheck cleans up stale pid file)
for i := 0; i < 5; i++ {
if !getGatewayStatus().running {
break
}
time.Sleep(200 * time.Millisecond)
}
return nil
}
func (a *App) newGatewayPage() tview.Primitive {
flex := tview.NewFlex().SetDirection(tview.FlexRow)
flex.SetBorder(true).
SetTitle(" [#00f0ff::b] GATEWAY MANAGEMENT ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
flex.SetBackgroundColor(tcell.NewHexColor(0x050510))
statusTV := tview.NewTextView().
SetDynamicColors(true).
SetTextAlign(tview.AlignCenter).
SetText("Checking status...")
statusTV.SetBackgroundColor(tcell.NewHexColor(0x050510))
var updateStatus func()
// 使用List作为按钮保证显示和交互正常
buttons := tview.NewList()
buttons.SetBackgroundColor(tcell.NewHexColor(0x050510))
buttons.SetMainTextColor(tcell.ColorWhite)
buttons.SetSelectedBackgroundColor(tcell.NewHexColor(0xff00ff))
buttons.SetSelectedTextColor(tcell.ColorBlack)
buttons.AddItem(" [lime]START[white] ", "", 0, func() {
if !getGatewayStatus().running {
err := startGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttons.AddItem(" [red]STOP[white] ", "", 0, func() {
if getGatewayStatus().running {
err := stopGateway()
if err != nil {
a.showError(err.Error())
}
updateStatus()
}
})
buttonFlex := tview.NewFlex().SetDirection(tview.FlexColumn)
buttonFlex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttons, 20, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
flex.
AddItem(tview.NewBox(), 0, 1, false).
AddItem(statusTV, 3, 1, false).
AddItem(tview.NewBox(), 0, 1, false).
AddItem(buttonFlex, 4, 1, true).
AddItem(tview.NewBox(), 0, 1, false)
updateStatus = func() {
status := getGatewayStatus()
if status.running {
versionInfo := ""
if status.version != "" {
versionInfo = fmt.Sprintf("\nVersion: %s", status.version)
}
statusTV.SetText(fmt.Sprintf("[#39ff14::b]GATEWAY RUNNING[-]\n\nPID: %d%s", status.pid, versionInfo))
buttons.SetItemText(0, " [gray]START[white] ", "")
buttons.SetItemText(1, " [red]STOP[white] ", "")
} else {
statusTV.SetText("[#ff2a2a::b]GATEWAY STOPPED[-]\n\nPID: N/A")
buttons.SetItemText(0, " [lime]START[white] ", "")
buttons.SetItemText(1, " [gray]STOP[white] ", "")
}
}
updateStatus()
done := make(chan struct{})
go func() {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
a.tapp.QueueUpdateDraw(updateStatus)
case <-done:
return
}
}
}()
originalInputCapture := flex.GetInputCapture()
flex.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
close(done)
return a.goBack()
}
if originalInputCapture != nil {
return originalInputCapture(event)
}
return event
})
a.pageRefreshFns["gateway"] = updateStatus
return a.buildShell("gateway", flex, " [#39ff14]Enter:[-] select [#ff2a2a]ESC:[-] back ")
}

View file

@ -0,0 +1,70 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"os"
"os/exec"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
func (a *App) newHomePage() tview.Primitive {
list := tview.NewList()
list.SetBorder(true).
SetTitle(" [#00f0ff::b] ACTIVE CONFIGURATION ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
list.SetMainTextColor(tcell.NewHexColor(0xe0e0e0))
list.SetSecondaryTextColor(tcell.NewHexColor(0x808080))
list.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0x39ff14)).Foreground(tcell.NewHexColor(0x050510)),
)
list.SetHighlightFullLine(true)
list.SetBackgroundColor(tcell.NewHexColor(0x050510))
rebuildList := func() {
sel := list.GetCurrentItem()
list.Clear()
list.AddItem("MODEL: "+a.cfg.CurrentModelLabel(), "Select to configure AI model", 'm', func() {
a.navigateTo("schemes", a.newSchemesPage())
})
list.AddItem(
"CHANNELS: Configure communication channels",
"Manage Telegram/Discord/WeChat channels",
'n',
func() {
a.navigateTo("channels", a.newChannelsPage())
},
)
list.AddItem("GATEWAY MANAGEMENT", "Manage PicoClaw gateway daemon", 'g', func() {
a.navigateTo("gateway", a.newGatewayPage())
})
list.AddItem("CHAT: Start AI agent chat", "Launch interactive chat session", 'c', func() {
a.tapp.Suspend(func() {
cmd := exec.Command("picoclaw", "agent")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
_ = cmd.Run()
})
})
list.AddItem("QUIT SYSTEM", "Exit PicoClaw Launcher", 'q', func() { a.tapp.Stop() })
if sel >= 0 && sel < list.GetItemCount() {
list.SetCurrentItem(sel)
}
}
rebuildList()
a.pageRefreshFns["home"] = rebuildList
return a.buildShell(
"home",
list,
" [#00f0ff]m:[-] model [#00f0ff]n:[-] channels [#00f0ff]g:[-] gateway [#00f0ff]c:[-] chat [#ff2a2a]q:[-] quit ",
)
}

View file

@ -0,0 +1,200 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
type modelsAPIResponse struct {
Data []modelEntry `json:"data"`
}
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
func (a *App) newModelsPage(schemeName, userName, baseURL string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false).
SetFixed(0, 0)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] MODELS · %s / %s ", schemeName, userName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
var modelIDs []string
status := tview.NewTextView().
SetTextAlign(tview.AlignCenter).
SetDynamicColors(true).
SetText("[#ffff00]FETCHING MODELS...[-]")
status.SetBackgroundColor(tcell.NewHexColor(0x050510))
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(status, 1, 0, false).
AddItem(table, 0, 1, false)
apiKey := a.resolveKey(schemeName, userName)
go func() {
var entries []modelEntry
var err error
if apiKey == "" {
err = fmt.Errorf("key is required")
} else {
entries, err = fetchModels(baseURL, apiKey)
}
a.modelCacheMu.Lock()
if a.modelCache == nil {
a.modelCache = make(map[string][]modelEntry)
}
if err == nil && len(entries) > 0 {
a.modelCache[cacheKey(schemeName, userName)] = entries
} else {
a.modelCache[cacheKey(schemeName, userName)] = nil
}
a.modelCacheMu.Unlock()
a.tapp.QueueUpdateDraw(func() {
if err != nil {
status.SetText(fmt.Sprintf("[#ff2a2a]ERROR: %s[-]", err.Error()))
table.SetCell(0, 0, tview.NewTableCell(" (failed to load models)"))
a.tapp.SetFocus(table)
return
}
if len(entries) == 0 {
status.SetText("[#ff2a2a]NO MODELS RETURNED[-]")
table.SetCell(0, 0, tview.NewTableCell(" (no models available)"))
a.tapp.SetFocus(table)
return
}
status.SetText(fmt.Sprintf("[#39ff14]%d MODEL(S) LOADED[-]", len(entries)))
for i, m := range entries {
modelIDs = append(modelIDs, m.ID)
table.SetCell(i, 0,
tview.NewTableCell(fmt.Sprintf("%3d", i+1)).
SetAlign(tview.AlignRight).
SetTextColor(tcell.NewHexColor(0x808080)).
SetSelectable(false),
)
table.SetCell(i, 1,
tview.NewTableCell(" "+m.ID).
SetAlign(tview.AlignLeft).
SetExpansion(1).
SetTextColor(tcell.NewHexColor(0xe0e0e0)),
)
}
a.tapp.SetFocus(table)
})
}()
table.SetSelectedFunc(func(row, _ int) {
if row < 0 || row >= len(modelIDs) {
return
}
a.cfg.Provider.Current = tuicfg.ProviderCurrent{
Scheme: schemeName,
User: userName,
Model: modelIDs[row],
}
a.save()
// Trigger model selected callback if set
if a.OnModelSelected != nil && a.cfg.Model.Type == "provider" {
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.goBack()
return
}
var user tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
user = u
break
}
}
a.OnModelSelected(*scheme, user, modelIDs[row])
}
a.goBack()
})
return a.buildShell("models", flex, " [#39ff14]Enter:[-] select [#ff00ff]ESC:[-] back ")
}
func (a *App) resolveKey(schemeName, userName string) string {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return u.Key
}
}
return ""
}
func fetchModels(baseURL, apiKey string) ([]modelEntry, error) {
url := strings.TrimRight(baseURL, "/") + "/models"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("build request: %w", err)
}
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
var result modelsAPIResponse
if err := json.Unmarshal(body, &result); err == nil && len(result.Data) > 0 {
return result.Data, nil
}
var arr []modelEntry
if err := json.Unmarshal(body, &arr); err == nil {
return arr, nil
}
return nil, fmt.Errorf(
"decode response: unrecognized shape: %s",
strings.TrimSpace(string(body[:min(len(body), 256)])),
)
}

View file

@ -0,0 +1,252 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newSchemesPage() tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(" [#00f0ff::b] PROVIDER SCHEMES ").
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
rowToIdx := func(row int) int { return row / 2 }
selectedSchemeName := func() string {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx >= 0 && idx < len(schemes) {
return schemes[idx].Name
}
return ""
}
rebuild := func() {
selName := selectedSchemeName()
table.Clear()
schemes := a.cfg.Provider.Schemes
for i, s := range schemes {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+s.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
users := a.cfg.Provider.UsersForScheme(s.Name)
n := len(users)
m := 0
for _, u := range users {
if models := a.cachedModels(s.Name, u.Name); len(models) > 0 {
m++
}
}
table.SetCell(detailRow, 0,
tview.NewTableCell(fmt.Sprintf(" [#808080](%d/%d) %s", m, n, s.BaseURL)).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+s.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, s := range schemes {
if s.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["schemes"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
if idx < 0 || idx >= len(schemes) {
return
}
name := schemes[idx].Name
a.navigateTo("users", a.newUsersPage(name))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
idx := rowToIdx(row)
schemes := a.cfg.Provider.Schemes
switch event.Rune() {
case 'a':
a.showSchemeForm(nil, func(s tuicfg.Scheme) {
a.cfg.Provider.Schemes = append(a.cfg.Provider.Schemes, s)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if idx < 0 || idx >= len(schemes) {
return nil
}
origName := schemes[idx].Name
orig := schemes[idx]
a.showSchemeForm(&orig, func(s tuicfg.Scheme) {
current := a.cfg.Provider.Schemes
for i, sc := range current {
if sc.Name == origName {
a.cfg.Provider.Schemes[i] = s
break
}
}
a.save()
a.refreshModelCache(func() {
rebuild()
for i, sc := range a.cfg.Provider.Schemes {
if sc.Name == s.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if idx < 0 || idx >= len(schemes) {
return nil
}
name := schemes[idx].Name
a.confirmDelete(fmt.Sprintf("scheme %q", name), func() {
current := a.cfg.Provider.Schemes
newSchemes := make([]tuicfg.Scheme, 0, len(current))
for _, sc := range current {
if sc.Name != name {
newSchemes = append(newSchemes, sc)
}
}
a.cfg.Provider.Schemes = newSchemes
existing := a.cfg.Provider.Users
filtered := make([]tuicfg.User, 0, len(existing))
for _, u := range existing {
if u.Scheme != name {
filtered = append(filtered, u)
}
}
a.cfg.Provider.Users = filtered
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"schemes",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] open [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showSchemeForm(existing *tuicfg.Scheme, onSave func(tuicfg.Scheme)) {
name := ""
baseURL := ""
schemeType := "openai-compatible"
title := " ADD SCHEME "
if existing != nil {
name = existing.Name
baseURL = existing.BaseURL
schemeType = existing.Type
title = " EDIT SCHEME "
}
typeOptions := []string{"openai-compatible", "anthropic"}
typeIdx := 0
for i, t := range typeOptions {
if t == schemeType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddInputField("Base URL", baseURL, 28, nil, func(text string) { baseURL = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { schemeType = option }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if baseURL == "" {
a.showError("Base URL is required")
return
}
if existing == nil {
for _, s := range a.cfg.Provider.Schemes {
if s.Name == name {
a.showError(fmt.Sprintf("Scheme name %q already exists", name))
return
}
}
}
a.hideModal("scheme-form")
onSave(tuicfg.Scheme{Name: name, BaseURL: baseURL, Type: schemeType})
}).
AddButton("CANCEL", func() {
a.hideModal("scheme-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("scheme-form")
return nil
}
return event
})
a.showModal("scheme-form", centeredForm(form, 4, 12))
}

View file

@ -0,0 +1,261 @@
// PicoClaw - Ultra-lightweight personal AI agent
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package ui
import (
"fmt"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
tuicfg "github.com/sipeed/picoclaw/cmd/picoclaw-launcher-tui/config"
)
func (a *App) newUsersPage(schemeName string) tview.Primitive {
table := tview.NewTable().
SetBorders(false).
SetSelectable(true, false)
table.SetBorder(true).
SetTitle(fmt.Sprintf(" [#00f0ff::b] USERS · %s ", schemeName)).
SetTitleColor(tcell.NewHexColor(0x00f0ff)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
table.SetSelectedStyle(
tcell.StyleDefault.Background(tcell.NewHexColor(0xff00ff)).Foreground(tcell.NewHexColor(0xffffff)),
)
table.SetBackgroundColor(tcell.NewHexColor(0x050510))
visibleUsers := func() []tuicfg.User {
var out []tuicfg.User
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName {
out = append(out, u)
}
}
return out
}
findUserGlobalIdx := func(userName string) int {
for i, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == userName {
return i
}
}
return -1
}
rowToVisIdx := func(row int) int { return row / 2 }
selectedUserName := func() string {
row, _ := table.GetSelection()
users := visibleUsers()
visIdx := rowToVisIdx(row)
if visIdx >= 0 && visIdx < len(users) {
return users[visIdx].Name
}
return ""
}
rebuild := func() {
selName := selectedUserName()
table.Clear()
users := visibleUsers()
for i, u := range users {
nameRow := i * 2
detailRow := nameRow + 1
table.SetCell(nameRow, 0,
tview.NewTableCell(" "+u.Name).
SetTextColor(tcell.NewHexColor(0xe0e0e0)).
SetExpansion(1).
SetSelectable(true),
)
table.SetCell(nameRow, 1,
tview.NewTableCell("").
SetSelectable(false),
)
models := a.cachedModels(schemeName, u.Name)
var detailText string
if len(models) > 0 {
detailText = fmt.Sprintf(" [#39ff14]%d models available[-]", len(models))
} else {
detailText = " [#ff2a2a]Inactive / No Access[-]"
}
table.SetCell(detailRow, 0,
tview.NewTableCell(detailText).
SetTextColor(tcell.NewHexColor(0x808080)).
SetExpansion(1).
SetSelectable(false),
)
table.SetCell(detailRow, 1,
tview.NewTableCell("[#00f0ff]"+u.Type+" ").
SetAlign(tview.AlignRight).
SetSelectable(false),
)
}
if selName != "" {
for i, u := range users {
if u.Name == selName {
table.Select(i*2, 0)
return
}
}
}
if table.GetRowCount() > 0 {
table.Select(0, 0)
}
}
rebuild()
a.refreshModelCache(rebuild)
a.pageRefreshFns["users"] = func() { a.refreshModelCache(rebuild) }
table.SetSelectedFunc(func(row, _ int) {
visIdx := rowToVisIdx(row)
users := visibleUsers()
if visIdx < 0 || visIdx >= len(users) {
return
}
uName := users[visIdx].Name
scheme := a.cfg.Provider.SchemeByName(schemeName)
if scheme == nil {
a.showError(fmt.Sprintf("Scheme %q not found", schemeName))
return
}
a.navigateTo("models", a.newModelsPage(schemeName, uName, scheme.BaseURL))
})
table.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
row, _ := table.GetSelection()
visIdx := rowToVisIdx(row)
users := visibleUsers()
switch event.Rune() {
case 'a':
a.showUserForm(schemeName, nil, func(u tuicfg.User) {
a.cfg.Provider.Users = append(a.cfg.Provider.Users, u)
a.save()
a.refreshModelCache(rebuild)
})
return nil
case 'e':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
origName := users[visIdx].Name
orig := a.cfg.Provider.Users[findUserGlobalIdx(origName)]
a.showUserForm(schemeName, &orig, func(u tuicfg.User) {
cfgIdx := findUserGlobalIdx(origName)
if cfgIdx < 0 {
a.showError(fmt.Sprintf("User %q no longer exists", origName))
return
}
a.cfg.Provider.Users[cfgIdx] = u
a.save()
a.refreshModelCache(func() {
rebuild()
for i, usr := range visibleUsers() {
if usr.Name == u.Name {
table.Select(i*2, 0)
break
}
}
})
})
return nil
case 'd':
if visIdx < 0 || visIdx >= len(users) {
return nil
}
uName := users[visIdx].Name
a.confirmDelete(fmt.Sprintf("user %q", uName), func() {
cfgIdx := findUserGlobalIdx(uName)
if cfgIdx < 0 {
return
}
all := a.cfg.Provider.Users
a.cfg.Provider.Users = append(all[:cfgIdx], all[cfgIdx+1:]...)
a.save()
a.refreshModelCache(rebuild)
})
return nil
}
return event
})
return a.buildShell(
"users",
table,
" [#00f0ff]a:[-] add [#00f0ff]e:[-] edit [#ff2a2a]d:[-] delete [#39ff14]Enter:[-] models [#ff00ff]ESC:[-] back ",
)
}
func (a *App) showUserForm(schemeName string, existing *tuicfg.User, onSave func(tuicfg.User)) {
name := ""
userType := "key"
key := ""
title := " ADD USER "
if existing != nil {
name = existing.Name
userType = existing.Type
key = existing.Key
title = " EDIT USER "
}
typeOptions := []string{"key", "OAuth"}
typeIdx := 0
for i, t := range typeOptions {
if t == userType {
typeIdx = i
break
}
}
form := tview.NewForm()
form.
AddInputField("Name", name, 20, nil, func(text string) { name = text }).
AddDropDown("Type", typeOptions, typeIdx, func(option string, _ int) { userType = option }).
AddPasswordField("Key", key, 28, '*', func(text string) { key = text }).
AddButton("SAVE", func() {
if name == "" {
a.showError("Name is required")
return
}
if existing == nil {
for _, u := range a.cfg.Provider.Users {
if u.Scheme == schemeName && u.Name == name {
a.showError(fmt.Sprintf("User name %q already exists for this scheme", name))
return
}
}
}
a.hideModal("user-form")
onSave(tuicfg.User{Name: name, Scheme: schemeName, Type: userType, Key: key})
}).
AddButton("CANCEL", func() {
a.hideModal("user-form")
})
form.SetBorder(true).
SetTitle(" [::b]" + title + " ").
SetTitleColor(tcell.NewHexColor(0x39ff14)).
SetBorderColor(tcell.NewHexColor(0x00f0ff))
form.SetBackgroundColor(tcell.NewHexColor(0x1a1a2e))
form.SetFieldBackgroundColor(tcell.NewHexColor(0x050510))
form.SetFieldTextColor(tcell.NewHexColor(0x00f0ff))
form.SetLabelColor(tcell.NewHexColor(0xe0e0e0))
form.SetButtonBackgroundColor(tcell.NewHexColor(0xff00ff))
form.SetButtonTextColor(tcell.NewHexColor(0xffffff))
form.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
if event.Key() == tcell.KeyEscape {
a.hideModal("user-form")
return nil
}
return event
})
a.showModal("user-form", centeredForm(form, 4, 13))
}

View file

@ -0,0 +1,64 @@
package main
import (
"context"
"net"
"net/http"
"os"
"strings"
"sync/atomic"
"time"
)
func init() {
// 仅在 /etc/resolv.conf 不存在时才覆盖(即 Android 环境)
if _, err := os.Stat("/etc/resolv.conf"); err == nil {
return
}
// 从环境变量获取 DNS server 列表,多个用 ; 隔开
// 例如: PICOCLAW_DNS_SERVER="8.8.8.8:53;1.1.1.1:53;223.5.5.5:53"
dnsEnv := os.Getenv("PICOCLAW_DNS_SERVER")
if dnsEnv == "" {
dnsEnv = "8.8.8.8:53;1.1.1.1:53"
}
var dnsServers []string
for _, s := range strings.Split(dnsEnv, ";") {
s = strings.TrimSpace(s)
if s != "" {
// 如果没有带端口号,自动补上 :53
if _, _, err := net.SplitHostPort(s); err != nil {
s = s + ":53"
}
dnsServers = append(dnsServers, s)
}
}
// 轮询索引,在多个 DNS 服务器之间轮转
var idx uint64
customResolver := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{Timeout: 5 * time.Second}
// Round-robin: 依次尝试不同的 DNS 服务器
server := dnsServers[atomic.AddUint64(&idx, 1)%uint64(len(dnsServers))]
return d.DialContext(ctx, "udp", server)
},
}
// 覆盖全局 DefaultResolver
net.DefaultResolver = customResolver
// 覆盖 http.DefaultTransport 使用自定义 DNS 解析的 DialContext
dialer := &net.Dialer{
Timeout: 30 * time.Second,
KeepAlive: 30 * time.Second,
Resolver: customResolver,
}
if tr, ok := http.DefaultTransport.(*http.Transport); ok {
tr.DialContext = dialer.DialContext
}
}

View file

@ -9,7 +9,7 @@ import (
"path/filepath"
"strings"
"github.com/chzyer/readline"
"github.com/ergochat/readline"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
@ -23,16 +23,18 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
sessionKey = "cli:default"
}
if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
}
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
logger.ConfigureFromEnv()
if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
}
if model != "" {
cfg.Agents.Defaults.ModelName = model
}
@ -50,6 +52,7 @@ func agentCmd(message, sessionKey, model string, debug bool) error {
msgBus := bus.NewMessageBus()
defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
defer agentLoop.Close()
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()

View file

@ -16,6 +16,8 @@ func NewAuthCommand() *cobra.Command {
newLogoutCommand(),
newStatusCommand(),
newModelsCommand(),
newWeixinCommand(),
newWeComCommand(),
)
return cmd

View file

@ -32,6 +32,8 @@ func TestNewAuthCommand(t *testing.T) {
"logout",
"status",
"models",
"weixin",
"wecom",
}
subcommands := cmd.Commands()

View file

@ -56,9 +56,6 @@ func authLoginOpenAI(useDeviceCode bool) error {
appCfg, err := internal.LoadConfig()
if err == nil {
// Update Providers (legacy format)
appCfg.Providers.OpenAI.AuthMethod = "oauth"
// Update or add openai in ModelList
foundOpenAI := false
for i := range appCfg.ModelList {
@ -71,15 +68,15 @@ func authLoginOpenAI(useDeviceCode bool) error {
// If no openai in ModelList, add it
if !foundOpenAI {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: "oauth",
})
}
// Update default model to use OpenAI
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
@ -90,7 +87,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
if cred.AccountID != "" {
fmt.Printf("Account: %s\n", cred.AccountID)
}
fmt.Println("Default model set to: gpt-5.2")
fmt.Println("Default model set to: gpt-5.4")
return nil
}
@ -130,9 +127,6 @@ func authLoginGoogleAntigravity() error {
appCfg, err := internal.LoadConfig()
if err == nil {
// Update Providers (legacy format, for backward compatibility)
appCfg.Providers.Antigravity.AuthMethod = "oauth"
// Update or add antigravity in ModelList
foundAntigravity := false
for i := range appCfg.ModelList {
@ -145,7 +139,7 @@ func authLoginGoogleAntigravity() error {
// If no antigravity in ModelList, add it
if !foundAntigravity {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: "gemini-flash",
Model: "antigravity/gemini-3-flash",
AuthMethod: "oauth",
@ -210,8 +204,6 @@ func authLoginAnthropicSetupToken() error {
appCfg, err := internal.LoadConfig()
if err == nil {
appCfg.Providers.Anthropic.AuthMethod = "oauth"
found := false
for i := range appCfg.ModelList {
if isAnthropicModel(appCfg.ModelList[i].Model) {
@ -221,7 +213,7 @@ func authLoginAnthropicSetupToken() error {
}
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: defaultAnthropicModel,
Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "oauth",
@ -287,7 +279,6 @@ func authLoginPasteToken(provider string) error {
if err == nil {
switch provider {
case "anthropic":
appCfg.Providers.Anthropic.AuthMethod = "token"
// Update ModelList
found := false
for i := range appCfg.ModelList {
@ -298,7 +289,7 @@ func authLoginPasteToken(provider string) error {
}
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: defaultAnthropicModel,
Model: "anthropic/" + defaultAnthropicModel,
AuthMethod: "token",
@ -306,7 +297,6 @@ func authLoginPasteToken(provider string) error {
appCfg.Agents.Defaults.ModelName = defaultAnthropicModel
}
case "openai":
appCfg.Providers.OpenAI.AuthMethod = "token"
// Update ModelList
found := false
for i := range appCfg.ModelList {
@ -317,14 +307,14 @@ func authLoginPasteToken(provider string) error {
}
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
appCfg.ModelList = append(appCfg.ModelList, &config.ModelConfig{
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
AuthMethod: "token",
})
}
// Update default model
appCfg.Agents.Defaults.ModelName = "gpt-5.2"
appCfg.Agents.Defaults.ModelName = "gpt-5.4"
}
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
@ -365,15 +355,6 @@ func authLogoutCmd(provider string) error {
}
}
}
// Clear AuthMethod in Providers (legacy)
switch provider {
case "openai":
appCfg.Providers.OpenAI.AuthMethod = ""
case "anthropic":
appCfg.Providers.Anthropic.AuthMethod = ""
case "google-antigravity", "antigravity":
appCfg.Providers.Antigravity.AuthMethod = ""
}
config.SaveConfig(internal.GetConfigPath(), appCfg)
}
@ -392,10 +373,6 @@ func authLogoutCmd(provider string) error {
for i := range appCfg.ModelList {
appCfg.ModelList[i].AuthMethod = ""
}
// Clear all AuthMethods in Providers (legacy)
appCfg.Providers.OpenAI.AuthMethod = ""
appCfg.Providers.Anthropic.AuthMethod = ""
appCfg.Providers.Antigravity.AuthMethod = ""
config.SaveConfig(internal.GetConfigPath(), appCfg)
}

View file

@ -0,0 +1,407 @@
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"runtime"
"strconv"
"strings"
"time"
"github.com/mdp/qrterminal/v3"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
const (
wecomQRSourceID = "picoclaw"
wecomQRGenerateEndpoint = "https://work.weixin.qq.com/ai/qc/generate"
wecomQRQueryEndpoint = "https://work.weixin.qq.com/ai/qc/query_result"
wecomQRPageEndpoint = "https://work.weixin.qq.com/ai/qc/gen"
wecomQRHTTPTimeout = 15 * time.Second
wecomQRPollInterval = 3 * time.Second
wecomQRPollTimeout = 5 * time.Minute
wecomDefaultWebSocketURL = "wss://openws.work.weixin.qq.com"
)
type wecomQRScanner func(context.Context, wecomQRFlowOptions) (wecomQRBotInfo, error)
type wecomQRFlowOptions struct {
HTTPClient *http.Client
GenerateURL string
QueryURL string
QRCodePageURL string
SourceID string
PollInterval time.Duration
PollTimeout time.Duration
Writer io.Writer
}
type wecomQRBotInfo struct {
BotID string
Secret string
}
type wecomQRSession struct {
SCode string
AuthURL string
}
type wecomQRGenerateResponse struct {
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Data struct {
SCode string `json:"scode"`
AuthURL string `json:"auth_url"`
} `json:"data"`
}
type wecomQRQueryResponse struct {
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
Data struct {
Status string `json:"status"`
BotInfo struct {
BotID string `json:"botid"`
Secret string `json:"secret"`
} `json:"bot_info"`
} `json:"data"`
}
func newWeComCommand() *cobra.Command {
var timeout time.Duration
cmd := &cobra.Command{
Use: "wecom",
Short: "Scan a WeCom QR code and configure channels.wecom",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
return authWeComCmd(timeout)
},
}
cmd.Flags().DurationVar(&timeout, "timeout", wecomQRPollTimeout, "How long to wait for QR confirmation")
return cmd
}
func authWeComCmd(timeout time.Duration) error {
return authWeComCmdWithScanner(context.Background(), os.Stdout, timeout, scanWeComQRCodeInteractive)
}
func authWeComCmdWithScanner(
ctx context.Context,
writer io.Writer,
timeout time.Duration,
scanner wecomQRScanner,
) error {
if scanner == nil {
return fmt.Errorf("wecom QR scanner is nil")
}
if writer == nil {
writer = os.Stdout
}
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
opts := defaultWeComQRFlowOptions(timeout)
opts.Writer = writer
botInfo, err := scanner(ctx, opts)
if err != nil {
return err
}
applyWeComAuthResult(cfg, botInfo)
if saveErr := config.SaveConfig(internal.GetConfigPath(), cfg); saveErr != nil {
return fmt.Errorf("failed to save config: %w", saveErr)
}
fmt.Fprintln(writer)
fmt.Fprintln(writer, "WeCom connected.")
fmt.Fprintf(writer, "Bot ID: %s\n", botInfo.BotID)
fmt.Fprintf(writer, "Config: %s\n", internal.GetConfigPath())
return nil
}
func defaultWeComQRFlowOptions(timeout time.Duration) wecomQRFlowOptions {
if timeout <= 0 {
timeout = wecomQRPollTimeout
}
return wecomQRFlowOptions{
HTTPClient: &http.Client{Timeout: wecomQRHTTPTimeout},
GenerateURL: wecomQRGenerateEndpoint,
QueryURL: wecomQRQueryEndpoint,
QRCodePageURL: wecomQRPageEndpoint,
SourceID: wecomQRSourceID,
PollInterval: wecomQRPollInterval,
PollTimeout: timeout,
Writer: os.Stdout,
}
}
func applyWeComAuthResult(cfg *config.Config, botInfo wecomQRBotInfo) {
cfg.Channels.WeCom.Enabled = true
cfg.Channels.WeCom.BotID = botInfo.BotID
cfg.Channels.WeCom.SetSecret(botInfo.Secret)
if strings.TrimSpace(cfg.Channels.WeCom.WebSocketURL) == "" {
cfg.Channels.WeCom.WebSocketURL = wecomDefaultWebSocketURL
}
}
func scanWeComQRCodeInteractive(ctx context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
opts = normalizeWeComQRFlowOptions(opts)
fmt.Fprintln(opts.Writer, "Requesting WeCom QR code...")
session, err := fetchWeComQRCode(ctx, opts)
if err != nil {
return wecomQRBotInfo{}, err
}
fmt.Fprintln(opts.Writer)
fmt.Fprintln(opts.Writer, "=======================================================")
fmt.Fprintln(opts.Writer, "Please scan the following QR code with WeCom:")
fmt.Fprintln(opts.Writer, "=======================================================")
fmt.Fprintln(opts.Writer)
qrterminal.GenerateWithConfig(session.AuthURL, qrterminal.Config{
Level: qrterminal.L,
Writer: opts.Writer,
HalfBlocks: true,
})
pageURL, err := buildWeComQRCodePageURL(opts.QRCodePageURL, opts.SourceID, session.SCode)
if err != nil {
return wecomQRBotInfo{}, err
}
fmt.Fprintln(opts.Writer)
fmt.Fprintf(opts.Writer, "QR Code Link: %s\n", pageURL)
fmt.Fprintln(opts.Writer)
fmt.Fprintln(opts.Writer, "Waiting for scan...")
return pollWeComQRCodeResult(ctx, opts, session.SCode)
}
func normalizeWeComQRFlowOptions(opts wecomQRFlowOptions) wecomQRFlowOptions {
if opts.HTTPClient == nil {
opts.HTTPClient = &http.Client{Timeout: wecomQRHTTPTimeout}
}
if strings.TrimSpace(opts.GenerateURL) == "" {
opts.GenerateURL = wecomQRGenerateEndpoint
}
if strings.TrimSpace(opts.QueryURL) == "" {
opts.QueryURL = wecomQRQueryEndpoint
}
if strings.TrimSpace(opts.QRCodePageURL) == "" {
opts.QRCodePageURL = wecomQRPageEndpoint
}
if strings.TrimSpace(opts.SourceID) == "" {
opts.SourceID = wecomQRSourceID
}
if opts.PollInterval <= 0 {
opts.PollInterval = wecomQRPollInterval
}
if opts.PollTimeout <= 0 {
opts.PollTimeout = wecomQRPollTimeout
}
if opts.Writer == nil {
opts.Writer = os.Stdout
}
return opts
}
func fetchWeComQRCode(ctx context.Context, opts wecomQRFlowOptions) (wecomQRSession, error) {
generateURL, err := buildWeComQRGenerateURL(opts.GenerateURL, opts.SourceID, wecomPlatformCode())
if err != nil {
return wecomQRSession{}, err
}
var resp wecomQRGenerateResponse
if err := doWeComJSONGet(ctx, opts.HTTPClient, generateURL, &resp); err != nil {
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: %w", err)
}
if resp.ErrCode != 0 {
return wecomQRSession{}, fmt.Errorf(
"failed to get WeCom QR code: errcode=%d errmsg=%s",
resp.ErrCode,
resp.ErrMsg,
)
}
if resp.Data.SCode == "" || resp.Data.AuthURL == "" {
return wecomQRSession{}, fmt.Errorf("failed to get WeCom QR code: response missing scode or auth_url")
}
return wecomQRSession{
SCode: resp.Data.SCode,
AuthURL: resp.Data.AuthURL,
}, nil
}
func pollWeComQRCodeResult(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRBotInfo, error) {
if strings.TrimSpace(scode) == "" {
return wecomQRBotInfo{}, fmt.Errorf("missing WeCom QR scode")
}
timeoutCtx, cancel := context.WithTimeout(ctx, opts.PollTimeout)
defer cancel()
var scannedPrinted bool
for {
status, err := queryWeComQRCodeStatus(timeoutCtx, opts, scode)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
}
return wecomQRBotInfo{}, err
}
switch strings.ToLower(status.Data.Status) {
case "success":
if status.Data.BotInfo.BotID == "" || status.Data.BotInfo.Secret == "" {
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan succeeded but bot credentials are missing")
}
return wecomQRBotInfo{
BotID: status.Data.BotInfo.BotID,
Secret: status.Data.BotInfo.Secret,
}, nil
case "expired":
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR code expired, please retry")
case "scaned", "scanned":
if !scannedPrinted {
fmt.Fprintln(opts.Writer, "QR code scanned. Confirm the login in WeCom.")
scannedPrinted = true
}
}
select {
case <-timeoutCtx.Done():
if errors.Is(timeoutCtx.Err(), context.DeadlineExceeded) {
return wecomQRBotInfo{}, fmt.Errorf("WeCom QR scan timed out after %s", opts.PollTimeout)
}
return wecomQRBotInfo{}, timeoutCtx.Err()
case <-time.After(opts.PollInterval):
}
}
}
func queryWeComQRCodeStatus(ctx context.Context, opts wecomQRFlowOptions, scode string) (wecomQRQueryResponse, error) {
queryURL, err := buildWeComQRQueryURL(opts.QueryURL, scode)
if err != nil {
return wecomQRQueryResponse{}, err
}
var resp wecomQRQueryResponse
if err := doWeComJSONGet(ctx, opts.HTTPClient, queryURL, &resp); err != nil {
return wecomQRQueryResponse{}, fmt.Errorf("failed to query WeCom QR result: %w", err)
}
if resp.ErrCode != 0 {
return wecomQRQueryResponse{}, fmt.Errorf(
"failed to query WeCom QR result: errcode=%d errmsg=%s",
resp.ErrCode,
resp.ErrMsg,
)
}
return resp, nil
}
func buildWeComQRGenerateURL(baseURL, sourceID string, platformCode int) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid WeCom QR generate URL: %w", err)
}
query := u.Query()
query.Set("source", sourceID)
query.Set("sourceID", sourceID)
query.Set("plat", strconv.Itoa(platformCode))
u.RawQuery = query.Encode()
return u.String(), nil
}
func buildWeComQRQueryURL(baseURL, scode string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid WeCom QR query URL: %w", err)
}
query := u.Query()
query.Set("scode", scode)
u.RawQuery = query.Encode()
return u.String(), nil
}
func buildWeComQRCodePageURL(baseURL, sourceID, scode string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("invalid WeCom QR page URL: %w", err)
}
query := u.Query()
query.Set("source", sourceID)
query.Set("sourceID", sourceID)
query.Set("scode", scode)
u.RawQuery = query.Encode()
return u.String(), nil
}
func doWeComJSONGet(ctx context.Context, client *http.Client, targetURL string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 8192))
if readErr != nil {
return fmt.Errorf("unexpected status %s", resp.Status)
}
return fmt.Errorf("unexpected status %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode JSON response: %w", err)
}
return nil
}
func wecomPlatformCode() int {
switch runtime.GOOS {
case "darwin":
return 1
case "windows":
return 2
case "linux":
return 3
default:
return 0
}
}

View file

@ -0,0 +1,157 @@
package auth
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewWeComCommand(t *testing.T) {
cmd := newWeComCommand()
require.NotNil(t, cmd)
assert.Equal(t, "wecom", cmd.Use)
assert.Equal(t, "Scan a WeCom QR code and configure channels.wecom", cmd.Short)
assert.NotNil(t, cmd.Flags().Lookup("timeout"))
}
func TestBuildWeComQRGenerateURL(t *testing.T) {
rawURL, err := buildWeComQRGenerateURL("https://example.com/ai/qc/generate", wecomQRSourceID, 3)
require.NoError(t, err)
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
assert.Equal(t, "3", parsed.Query().Get("plat"))
}
func TestBuildWeComQRCodePageURL(t *testing.T) {
rawURL, err := buildWeComQRCodePageURL("https://example.com/ai/qc/gen", wecomQRSourceID, "scode-1")
require.NoError(t, err)
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("source"))
assert.Equal(t, wecomQRSourceID, parsed.Query().Get("sourceID"))
assert.Equal(t, "scode-1", parsed.Query().Get("scode"))
}
func TestFetchWeComQRCode(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/generate", r.URL.Path)
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("source"))
assert.Equal(t, wecomQRSourceID, r.URL.Query().Get("sourceID"))
assert.Equal(t, strconv.Itoa(wecomPlatformCode()), r.URL.Query().Get("plat"))
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"scode":"scode-1","auth_url":"https://example.com/qr"}}`))
}))
defer server.Close()
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
HTTPClient: server.Client(),
GenerateURL: server.URL + "/generate",
Writer: bytes.NewBuffer(nil),
})
session, err := fetchWeComQRCode(context.Background(), opts)
require.NoError(t, err)
assert.Equal(t, "scode-1", session.SCode)
assert.Equal(t, "https://example.com/qr", session.AuthURL)
}
func TestPollWeComQRCodeResult(t *testing.T) {
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
call := calls.Add(1)
assert.Equal(t, "/query", r.URL.Path)
assert.Equal(t, "scode-1", r.URL.Query().Get("scode"))
w.Header().Set("Content-Type", "application/json")
switch call {
case 1:
_, _ = w.Write([]byte(`{"data":{"status":"wait"}}`))
case 2:
_, _ = w.Write([]byte(`{"data":{"status":"scaned"}}`))
default:
_, _ = w.Write([]byte(`{"data":{"status":"success","bot_info":{"botid":"bot-1","secret":"secret-1"}}}`))
}
}))
defer server.Close()
var output bytes.Buffer
opts := normalizeWeComQRFlowOptions(wecomQRFlowOptions{
HTTPClient: server.Client(),
QueryURL: server.URL + "/query",
PollInterval: time.Millisecond,
PollTimeout: time.Second,
Writer: &output,
})
botInfo, err := pollWeComQRCodeResult(context.Background(), opts, "scode-1")
require.NoError(t, err)
assert.Equal(t, "bot-1", botInfo.BotID)
assert.Equal(t, "secret-1", botInfo.Secret)
assert.Contains(t, output.String(), "QR code scanned. Confirm the login in WeCom.")
}
func TestApplyWeComAuthResult(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Channels.WeCom.WebSocketURL = ""
applyWeComAuthResult(cfg, wecomQRBotInfo{
BotID: "bot-1",
Secret: "secret-1",
})
assert.True(t, cfg.Channels.WeCom.Enabled)
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
}
func TestAuthWeComCmdWithScanner(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
t.Setenv(config.EnvHome, tmpDir)
t.Setenv(config.EnvConfig, configPath)
var output bytes.Buffer
err := authWeComCmdWithScanner(
context.Background(),
&output,
time.Second,
func(_ context.Context, opts wecomQRFlowOptions) (wecomQRBotInfo, error) {
assert.Equal(t, wecomQRSourceID, opts.SourceID)
return wecomQRBotInfo{
BotID: "bot-1",
Secret: "secret-1",
}, nil
},
)
require.NoError(t, err)
cfg, err := config.LoadConfig(internal.GetConfigPath())
require.NoError(t, err)
assert.True(t, cfg.Channels.WeCom.Enabled)
assert.Equal(t, "bot-1", cfg.Channels.WeCom.BotID)
assert.Equal(t, "secret-1", cfg.Channels.WeCom.Secret.String())
assert.Equal(t, wecomDefaultWebSocketURL, cfg.Channels.WeCom.WebSocketURL)
assert.Contains(t, output.String(), "WeCom connected.")
}

View file

@ -0,0 +1,124 @@
package auth
import (
"context"
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/channels/weixin"
"github.com/sipeed/picoclaw/pkg/config"
)
func newWeixinCommand() *cobra.Command {
var baseURL string
var proxy string
var timeout int
cmd := &cobra.Command{
Use: "weixin",
Short: "Connect a WeChat personal account via QR code",
Long: `Start the interactive Weixin (WeChat personal) QR code login flow.
A QR code is displayed in the terminal. Scan it with the WeChat mobile app
to authorize your account. On success, the bot token is saved to the picoclaw
config so you can start the gateway immediately.
Example:
picoclaw auth weixin`,
RunE: func(cmd *cobra.Command, _ []string) error {
return runWeixinOnboard(baseURL, proxy, time.Duration(timeout)*time.Second)
},
}
cmd.Flags().StringVar(&baseURL, "base-url", "https://ilinkai.weixin.qq.com/", "iLink API base URL")
cmd.Flags().StringVar(&proxy, "proxy", "", "HTTP proxy URL (e.g. http://localhost:7890)")
cmd.Flags().IntVar(&timeout, "timeout", 300, "Login timeout in seconds")
return cmd
}
func runWeixinOnboard(baseURL, proxy string, timeout time.Duration) error {
fmt.Println("Starting Weixin (WeChat personal) login...")
fmt.Println()
botToken, userID, accountID, returnedBaseURL, err := weixin.PerformLoginInteractive(
context.Background(),
weixin.AuthFlowOpts{
BaseURL: baseURL,
Timeout: timeout,
Proxy: proxy,
},
)
if err != nil {
return fmt.Errorf("login failed: %w", err)
}
fmt.Println()
fmt.Println("✅ Login successful!")
fmt.Printf(" Account ID : %s\n", accountID)
if userID != "" {
fmt.Printf(" User ID : %s\n", userID)
}
fmt.Println()
// Prefer the server-returned base URL (may be region-specific)
effectiveBaseURL := returnedBaseURL
if effectiveBaseURL == "" {
effectiveBaseURL = baseURL
}
if err := saveWeixinConfig(botToken, effectiveBaseURL, proxy); err != nil {
fmt.Printf("⚠️ Could not auto-save to config: %v\n", err)
printManualWeixinConfig(botToken, effectiveBaseURL)
return nil
}
fmt.Println("✓ Config updated. Start the gateway with:")
fmt.Println()
fmt.Println(" picoclaw gateway")
fmt.Println()
fmt.Println("To restrict which WeChat users can send messages, add their user IDs")
fmt.Println("to channels.weixin.allow_from in your config.")
return nil
}
// saveWeixinConfig patches channels.weixin in the config and saves it.
func saveWeixinConfig(token, baseURL, proxy string) error {
cfgPath := internal.GetConfigPath()
cfg, err := config.LoadConfig(cfgPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
cfg.Channels.Weixin.Enabled = true
cfg.Channels.Weixin.SetToken(token)
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
cfg.Channels.Weixin.BaseURL = baseURL
}
if proxy != "" {
cfg.Channels.Weixin.Proxy = proxy
}
return config.SaveConfig(cfgPath, cfg)
}
func printManualWeixinConfig(token, baseURL string) {
fmt.Println()
fmt.Println("Add the following to the channels section of your picoclaw config:")
fmt.Println()
fmt.Println(` "weixin": {`)
fmt.Println(` "enabled": true,`)
fmt.Printf(" \"token\": %q,\n", token)
const defaultBase = "https://ilinkai.weixin.qq.com/"
if baseURL != "" && baseURL != defaultBase {
fmt.Printf(" \"base_url\": %q,\n", baseURL)
}
fmt.Println(` "allow_from": []`)
fmt.Println(` }`)
}

View file

@ -14,7 +14,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
message string
every int64
cronExp string
deliver bool
channel string
to string
)
@ -37,7 +36,7 @@ func newAddCommand(storePath func() string) *cobra.Command {
}
cs := cron.NewCronService(storePath(), nil)
job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
job, err := cs.AddJob(name, schedule, message, channel, to)
if err != nil {
return fmt.Errorf("error adding job: %w", err)
}
@ -52,7 +51,6 @@ func newAddCommand(storePath func() string) *cobra.Command {
cmd.Flags().StringVarP(&message, "message", "m", "", "Message for agent")
cmd.Flags().Int64VarP(&every, "every", "e", 0, "Run every N seconds")
cmd.Flags().StringVarP(&cronExp, "cron", "c", "", "Cron expression (e.g. '0 9 * * *')")
cmd.Flags().BoolVarP(&deliver, "deliver", "d", false, "Deliver response to channel")
cmd.Flags().StringVar(&to, "to", "", "Recipient for delivery")
cmd.Flags().StringVar(&channel, "channel", "", "Channel for delivery")

View file

@ -21,7 +21,6 @@ func TestNewAddSubcommand(t *testing.T) {
assert.NotNil(t, cmd.Flags().Lookup("every"))
assert.NotNil(t, cmd.Flags().Lookup("cron"))
assert.NotNil(t, cmd.Flags().Lookup("deliver"))
assert.NotNil(t, cmd.Flags().Lookup("to"))
assert.NotNil(t, cmd.Flags().Lookup("channel"))

View file

@ -1,23 +1,52 @@
package gateway
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/gateway"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
func NewGatewayCommand() *cobra.Command {
var debug bool
var noTruncate bool
var allowEmpty bool
cmd := &cobra.Command{
Use: "gateway",
Aliases: []string{"g"},
Short: "Start picoclaw gateway",
Args: cobra.NoArgs,
PreRunE: func(_ *cobra.Command, _ []string) error {
if noTruncate && !debug {
return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)")
}
if noTruncate {
utils.SetDisableTruncation(true)
logger.Info("String truncation is globally disabled via 'no-truncate' flag")
}
return nil
},
RunE: func(_ *cobra.Command, _ []string) error {
return gatewayCmd(debug)
return gateway.Run(debug, internal.GetPicoclawHome(), internal.GetConfigPath(), allowEmpty)
},
}
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
cmd.Flags().BoolVarP(
&allowEmpty,
"allow-empty",
"E",
false,
"Continue starting even when no default model is configured",
)
return cmd
}

View file

@ -28,4 +28,5 @@ func TestNewGatewayCommand(t *testing.T) {
assert.True(t, cmd.HasFlags())
assert.NotNil(t, cmd.Flags().Lookup("debug"))
assert.NotNil(t, cmd.Flags().Lookup("allow-empty"))
}

View file

@ -1,256 +0,0 @@
package gateway
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"time"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/agent"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
_ "github.com/sipeed/picoclaw/pkg/channels/dingtalk"
_ "github.com/sipeed/picoclaw/pkg/channels/discord"
_ "github.com/sipeed/picoclaw/pkg/channels/feishu"
_ "github.com/sipeed/picoclaw/pkg/channels/irc"
_ "github.com/sipeed/picoclaw/pkg/channels/line"
_ "github.com/sipeed/picoclaw/pkg/channels/maixcam"
_ "github.com/sipeed/picoclaw/pkg/channels/matrix"
_ "github.com/sipeed/picoclaw/pkg/channels/onebot"
_ "github.com/sipeed/picoclaw/pkg/channels/pico"
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
_ "github.com/sipeed/picoclaw/pkg/channels/telegram"
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp"
_ "github.com/sipeed/picoclaw/pkg/channels/whatsapp_native"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
"github.com/sipeed/picoclaw/pkg/devices"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/heartbeat"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/voice"
)
func gatewayCmd(debug bool) error {
if debug {
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
}
cfg, err := internal.LoadConfig()
if err != nil {
return fmt.Errorf("error loading config: %w", err)
}
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
return fmt.Errorf("error creating provider: %w", err)
}
// Use the resolved model ID from provider creation
if modelID != "" {
cfg.Agents.Defaults.ModelName = modelID
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
// Print agent startup info
fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]any)
skillsInfo := startupInfo["skills"].(map[string]any)
fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
fmt.Printf(" • Skills: %d/%d available\n",
skillsInfo["available"],
skillsInfo["total"])
// Log to file as well
logger.InfoCF("agent", "Agent initialized",
map[string]any{
"tools_count": toolsInfo["count"],
"skills_total": skillsInfo["total"],
"skills_available": skillsInfo["available"],
})
// Setup cron tool and service
execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
cronService := setupCronTool(
agentLoop,
msgBus,
cfg.WorkspacePath(),
cfg.Agents.Defaults.RestrictToWorkspace,
execTimeout,
cfg,
)
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled,
)
heartbeatService.SetBus(msgBus)
heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
// Use cli:direct as fallback if no valid channel
if channel == "" || chatID == "" {
channel, chatID = "cli", "direct"
}
// Use ProcessHeartbeat - no session history, each heartbeat is independent
var response string
response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
}
if response == "HEARTBEAT_OK" {
return tools.SilentResult("Heartbeat OK")
}
// For heartbeat, always return silent - the subagent result will be
// sent to user via processSystemMessage when the async task completes
return tools.SilentResult(response)
})
// Create media store for file lifecycle management with TTL cleanup
mediaStore := media.NewFileMediaStoreWithCleanup(media.MediaCleanerConfig{
Enabled: cfg.Tools.MediaCleanup.Enabled,
MaxAge: time.Duration(cfg.Tools.MediaCleanup.MaxAge) * time.Minute,
Interval: time.Duration(cfg.Tools.MediaCleanup.Interval) * time.Minute,
})
mediaStore.Start()
channelManager, err := channels.NewManager(cfg, msgBus, mediaStore)
if err != nil {
mediaStore.Stop()
return fmt.Errorf("error creating channel manager: %w", err)
}
// Inject channel manager and media store into agent loop
agentLoop.SetChannelManager(channelManager)
agentLoop.SetMediaStore(mediaStore)
// Wire up voice transcription if a supported provider is configured.
if transcriber := voice.DetectTranscriber(cfg); transcriber != nil {
agentLoop.SetTranscriber(transcriber)
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
}
enabledChannels := channelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
} else {
fmt.Println("⚠ Warning: No channels enabled")
}
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := cronService.Start(); err != nil {
fmt.Printf("Error starting cron service: %v\n", err)
}
fmt.Println("✓ Cron service started")
if err := heartbeatService.Start(); err != nil {
fmt.Printf("Error starting heartbeat service: %v\n", err)
}
fmt.Println("✓ Heartbeat service started")
stateManager := state.NewManager(cfg.WorkspacePath())
deviceService := devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager)
deviceService.SetBus(msgBus)
if err := deviceService.Start(ctx); err != nil {
fmt.Printf("Error starting device service: %v\n", err)
} else if cfg.Devices.Enabled {
fmt.Println("✓ Device event service started")
}
// Setup shared HTTP server with health endpoints and webhook handlers
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
addr := fmt.Sprintf("%s:%d", cfg.Gateway.Host, cfg.Gateway.Port)
channelManager.SetupHTTPServer(addr, healthServer)
if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
return err
}
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
go agentLoop.Run(ctx)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
<-sigChan
fmt.Println("\nShutting down...")
if cp, ok := provider.(providers.StatefulProvider); ok {
cp.Close()
}
cancel()
msgBus.Close()
// Use a fresh context with timeout for graceful shutdown,
// since the original ctx is already canceled.
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
defer shutdownCancel()
channelManager.StopAll(shutdownCtx)
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
mediaStore.Stop()
agentLoop.Stop()
fmt.Println("✓ Gateway stopped")
return nil
}
func setupCronTool(
agentLoop *agent.AgentLoop,
msgBus *bus.MessageBus,
workspace string,
restrict bool,
execTimeout time.Duration,
cfg *config.Config,
) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool if enabled
var cronTool *tools.CronTool
if cfg.Tools.IsToolEnabled("cron") {
var err error
cronTool, err = tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
if err != nil {
log.Fatalf("Critical error during CronTool initialization: %v", err)
}
agentLoop.RegisterTool(cronTool)
}
// Set onJob handler
if cronTool != nil {
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
result := cronTool.ExecuteJob(context.Background(), job)
return result, nil
})
}
return cronService
}

View file

@ -1,64 +1,52 @@
package internal
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/sipeed/picoclaw/pkg"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
const Logo = "🦞"
var (
version = "dev"
gitCommit string
buildTime string
goVersion string
)
const Logo = pkg.Logo
// GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
return home
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw")
return config.GetHome()
}
func GetConfigPath() string {
if configPath := os.Getenv("PICOCLAW_CONFIG"); configPath != "" {
if configPath := os.Getenv(config.EnvConfig); configPath != "" {
return configPath
}
return filepath.Join(GetPicoclawHome(), "config.json")
}
func LoadConfig() (*config.Config, error) {
return config.LoadConfig(GetConfigPath())
cfg, err := config.LoadConfig(GetConfigPath())
if err != nil {
return nil, err
}
logger.SetLevelFromString(cfg.Gateway.LogLevel)
return cfg, nil
}
// FormatVersion returns the version string with optional git commit
// Deprecated: Use pkg/config.FormatVersion instead
func FormatVersion() string {
v := version
if gitCommit != "" {
v += fmt.Sprintf(" (git: %s)", gitCommit)
}
return v
return config.FormatVersion()
}
// FormatBuildInfo returns build time and go version info
// Deprecated: Use pkg/config.FormatBuildInfo instead
func FormatBuildInfo() (string, string) {
build := buildTime
goVer := goVersion
if goVer == "" {
goVer = runtime.Version()
}
return build, goVer
return config.FormatBuildInfo()
}
// GetVersion returns the version string
// Deprecated: Use pkg/config.GetVersion instead
func GetVersion() string {
return version
return config.GetVersion()
}

View file

@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestGetConfigPath(t *testing.T) {
@ -20,7 +22,7 @@ func TestGetConfigPath(t *testing.T) {
}
func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
t.Setenv(config.EnvHome, "/custom/picoclaw")
t.Setenv("HOME", "/tmp/home")
got := GetConfigPath()
@ -31,7 +33,7 @@ func TestGetConfigPath_WithPICOCLAW_HOME(t *testing.T) {
func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
t.Setenv("PICOCLAW_CONFIG", "/custom/config.json")
t.Setenv("PICOCLAW_HOME", "/custom/picoclaw")
t.Setenv(config.EnvHome, "/custom/picoclaw")
t.Setenv("HOME", "/tmp/home")
got := GetConfigPath()
@ -40,65 +42,6 @@ func TestGetConfigPath_WithPICOCLAW_CONFIG(t *testing.T) {
assert.Equal(t, want, got)
}
func TestFormatVersion_NoGitCommit(t *testing.T) {
oldVersion, oldGit := version, gitCommit
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
version = "1.2.3"
gitCommit = ""
assert.Equal(t, "1.2.3", FormatVersion())
}
func TestFormatVersion_WithGitCommit(t *testing.T) {
oldVersion, oldGit := version, gitCommit
t.Cleanup(func() { version, gitCommit = oldVersion, oldGit })
version = "1.2.3"
gitCommit = "abc123"
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
}
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = "2026-02-20T00:00:00Z"
goVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Equal(t, buildTime, build)
assert.Equal(t, goVersion, goVer)
}
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = ""
goVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Empty(t, build)
assert.Equal(t, goVersion, goVer)
}
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
oldBuildTime, oldGoVersion := buildTime, goVersion
t.Cleanup(func() { buildTime, goVersion = oldBuildTime, oldGoVersion })
buildTime = "x"
goVersion = ""
build, goVer := FormatBuildInfo()
assert.Equal(t, "x", build)
assert.Equal(t, runtime.Version(), goVer)
}
func TestGetConfigPath_Windows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("windows-specific HOME behavior varies; run on windows")
@ -112,17 +55,3 @@ func TestGetConfigPath_Windows(t *testing.T) {
require.True(t, strings.EqualFold(got, want), "GetConfigPath() = %q, want %q", got, want)
}
func TestGetVersion(t *testing.T) {
assert.Equal(t, "dev", GetVersion())
}
func TestGetConfigPath_WithEnv(t *testing.T) {
t.Setenv("PICOCLAW_CONFIG", "/tmp/custom/config.json")
t.Setenv("HOME", "/tmp/home") // Also set home to ensure env is preferred
got := GetConfigPath()
want := "/tmp/custom/config.json"
assert.Equal(t, want, got)
}

View file

@ -0,0 +1,128 @@
package model
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
// LocalModel is a special model name that indicates that the model is local and with or without api_key.
const LocalModel = "local-model"
func NewModelCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "model [model_name]",
Short: "Show or change the default model",
Long: `Show or change the default model configuration.
If no argument is provided, shows the current default model.
If a model name is provided, sets it as the default model.
Examples:
picoclaw model # Show current default model
picoclaw model gpt-5.2 # Set gpt-5.2 as default
picoclaw model claude-sonnet-4.6 # Set claude-sonnet-4.6 as default
picoclaw model local-model # Set local VLLM server as default
Note: 'local-model' is a special value for using a local VLLM server
(running at localhost:8000 by default) which does not require an API key.`,
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
configPath := internal.GetConfigPath()
// Load current config
cfg, err := config.LoadConfig(configPath)
if err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
if len(args) == 0 {
// Show current default model
showCurrentModel(cfg)
return nil
}
// Set new default model
modelName := args[0]
return setDefaultModel(configPath, cfg, modelName)
},
}
return cmd
}
func showCurrentModel(cfg *config.Config) {
defaultModel := cfg.Agents.Defaults.ModelName
if defaultModel == "" {
fmt.Println("No default model is currently set.")
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
} else {
fmt.Printf("Current default model: %s\n", defaultModel)
fmt.Println("\nAvailable models in your config:")
listAvailableModels(cfg)
}
}
func listAvailableModels(cfg *config.Config) {
if len(cfg.ModelList) == 0 {
fmt.Println(" No models configured in model_list")
return
}
defaultModel := cfg.Agents.Defaults.ModelName
for _, model := range cfg.ModelList {
marker := " "
if model.ModelName == defaultModel {
marker = "> "
}
if !model.Enabled {
continue
}
fmt.Printf("%s- %s (%s)\n", marker, model.ModelName, model.Model)
}
}
func setDefaultModel(configPath string, cfg *config.Config, modelName string) error {
// Validate that the model exists in model_list
modelFound := false
for _, model := range cfg.ModelList {
if model.Enabled && model.ModelName == modelName {
modelFound = true
break
}
}
if !modelFound && modelName != LocalModel {
return fmt.Errorf("cannot found model '%s' in config", modelName)
}
// Update the default model
// Clear old model field and set new model_name
oldModel := cfg.Agents.Defaults.ModelName
cfg.Agents.Defaults.ModelName = modelName
// Save config back to file
if err := config.SaveConfig(configPath, cfg); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
fmt.Printf("✓ Default model changed from '%s' to '%s'\n",
formatModelName(oldModel), modelName)
fmt.Println("\nThe new default model will be used for all agent interactions.")
return nil
}
func formatModelName(name string) string {
if name == "" {
return "(none)"
}
return name
}

View file

@ -0,0 +1,408 @@
package model
import (
"bytes"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/pkg/config"
)
var configPath = ""
func initTest(t *testing.T) {
tmpDir := t.TempDir()
configPath = filepath.Join(tmpDir, "config.json")
_ = os.Setenv("PICOCLAW_CONFIG", configPath)
}
// captureStdout captures stdout during the execution of fn and returns the captured output
func captureStdout(fn func()) string {
oldStdout := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
fn()
w.Close()
os.Stdout = oldStdout
var buf bytes.Buffer
io.Copy(&buf, r)
return buf.String()
}
func TestNewModelCommand(t *testing.T) {
cmd := NewModelCommand()
require.NotNil(t, cmd)
assert.Equal(t, "model [model_name]", cmd.Use)
assert.Equal(t, "Show or change the default model", cmd.Short)
assert.Len(t, cmd.Aliases, 0)
assert.False(t, cmd.HasFlags())
assert.Nil(t, cmd.Run)
assert.NotNil(t, cmd.RunE)
assert.Nil(t, cmd.PersistentPreRunE)
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
}
func TestShowCurrentModel_WithDefaultModel(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "claude-3",
Model: "anthropic/claude-3",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "Current default model: gpt-4")
assert.Contains(t, output, "Available models in your config:")
assert.Contains(t, output, "gpt-4")
assert.Contains(t, output, "claude-3")
}
func TestShowCurrentModel_NoDefaultModel(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
output := captureStdout(func() {
showCurrentModel(cfg)
})
assert.Contains(t, output, "No default model is currently set.")
assert.Contains(t, output, "Available models in your config:")
}
func TestListAvailableModels_Empty(t *testing.T) {
cfg := &config.Config{
ModelList: []*config.ModelConfig{},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.Contains(t, output, "No models configured in model_list")
}
func TestListAvailableModels_WithModels(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "gpt-4",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "gpt-4",
Model: "openai/gpt-4",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "claude-3",
Model: "anthropic/claude-3",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{ModelName: "no-key-model", Model: "openai/test"},
},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.NotEmpty(t, output)
assert.Contains(t, output, "> - gpt-4 (openai/gpt-4)")
assert.Contains(t, output, "claude-3 (anthropic/claude-3)")
assert.NotContains(t, output, "no-key-model")
}
func TestSetDefaultModel_ValidModel(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "new-model",
Model: "openai/new-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "old-model",
Model: "openai/old-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
output := captureStdout(func() {
err := setDefaultModel(configPath, cfg, "new-model")
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
// Verify config was updated
updatedCfg, err := config.LoadConfig(configPath)
require.NoError(t, err)
assert.Equal(t, "new-model", updatedCfg.Agents.Defaults.ModelName)
}
func TestSetDefaultModel_InvalidModel(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "existing-model",
Model: "openai/existing",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
assert.Error(t, setDefaultModel(configPath, cfg, "nonexistent-model"))
}
func TestSetDefaultModel_ModelWithoutAPIKey(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "existing-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "existing-model",
Model: "openai/existing",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{ModelName: "no-key-model", Model: "openai/nokey"},
},
}
assert.Error(t, setDefaultModel(configPath, cfg, "no-key-model"))
}
func TestSetDefaultModel_SaveConfigError(t *testing.T) {
// Use an invalid path to trigger save error
invalidPath := "/nonexistent/directory/config.json"
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "new-model",
Model: "openai/new-model",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
err := setDefaultModel(invalidPath, cfg, "new-model")
assert.Error(t, err)
assert.Contains(t, err.Error(), "failed to save config")
}
func TestFormatModelName(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", "(none)"},
{"simple model", "gpt-4", "gpt-4"},
{"model with version", "claude-sonnet-4.6", "claude-sonnet-4.6"},
{"model with spaces", "my model", "my model"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := formatModelName(tt.input)
assert.Equal(t, tt.expected, result)
})
}
}
func TestModelCommandExecution_Show(t *testing.T) {
initTest(t)
// Create a test config
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "test-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "test-model",
Model: "openai/test",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
cmd := NewModelCommand()
output := captureStdout(func() {
err = cmd.RunE(cmd, []string{})
assert.NoError(t, err)
})
assert.Contains(t, output, "Current default model: test-model")
}
func TestModelCommandExecution_Set(t *testing.T) {
initTest(t)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "old-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "old-model",
Model: "openai/old",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "new-model",
Model: "openai/new",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
err := config.SaveConfig(configPath, cfg)
require.NoError(t, err)
cmd := NewModelCommand()
output := captureStdout(func() {
err = cmd.RunE(cmd, []string{"new-model"})
assert.NoError(t, err)
})
assert.Contains(t, output, "Default model changed from 'old-model' to 'new-model'")
}
func TestModelCommandExecution_TooManyArgs(t *testing.T) {
cmd := NewModelCommand()
err := cmd.RunE(cmd, []string{"model1", "model2"})
assert.Error(t, err)
}
func TestListAvailableModels_MarkerLogic(t *testing.T) {
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
ModelName: "middle-model",
},
},
ModelList: []*config.ModelConfig{
{
ModelName: "first-model",
Model: "openai/first",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "middle-model",
Model: "openai/middle",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
{
ModelName: "last-model",
Model: "openai/last",
APIKeys: config.SecureStrings{config.NewSecureString("test")},
Enabled: true,
},
},
}
output := captureStdout(func() {
listAvailableModels(cfg)
})
assert.Contains(t, output, " - first-model (openai/first)")
assert.Contains(t, output, "> - middle-model (openai/middle)")
assert.Contains(t, output, " - last-model (openai/last)")
}

View file

@ -11,14 +11,24 @@ import (
var embeddedFiles embed.FS
func NewOnboardCommand() *cobra.Command {
var encrypt bool
cmd := &cobra.Command{
Use: "onboard",
Aliases: []string{"o"},
Short: "Initialize picoclaw configuration and workspace",
// Run without subcommands → original onboard flow
Run: func(cmd *cobra.Command, args []string) {
onboard()
if len(args) == 0 {
onboard(encrypt)
} else {
_ = cmd.Help()
}
},
}
cmd.Flags().BoolVar(&encrypt, "enc", false,
"Enable credential encryption (generates SSH key and prompts for passphrase)")
return cmd
}

View file

@ -24,6 +24,9 @@ func TestNewOnboardCommand(t *testing.T) {
assert.Nil(t, cmd.PersistentPreRun)
assert.Nil(t, cmd.PersistentPostRun)
assert.False(t, cmd.HasFlags())
assert.True(t, cmd.HasFlags())
encFlag := cmd.Flags().Lookup("enc")
require.NotNil(t, encFlag, "expected --enc flag to be registered")
assert.Equal(t, "false", encFlag.DefValue, "--enc should default to false")
assert.False(t, cmd.HasSubCommands())
}

View file

@ -6,25 +6,71 @@ import (
"os"
"path/filepath"
"golang.org/x/term"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/credential"
)
func onboard() {
func onboard(encrypt bool) {
configPath := internal.GetConfigPath()
configExists := false
if _, err := os.Stat(configPath); err == nil {
configExists = true
if encrypt {
// Only ask for confirmation when *both* config and SSH key already exist,
// indicating a full re-onboard that would reset the config to defaults.
sshKeyPath, _ := credential.DefaultSSHKeyPath()
if _, err := os.Stat(sshKeyPath); err == nil {
// Both exist — confirm a full reset.
fmt.Printf("Config already exists at %s\n", configPath)
fmt.Print("Overwrite? (y/n): ")
fmt.Print("Overwrite config with defaults? (y/n): ")
var response string
fmt.Scanln(&response)
if response != "y" {
fmt.Println("Aborted.")
return
}
configExists = false // user agreed to reset; treat as fresh
}
// Config exists but SSH key is missing — keep existing config, only add SSH key.
}
}
cfg := config.DefaultConfig()
var err error
if encrypt {
fmt.Println("\nSet up credential encryption")
fmt.Println("-----------------------------")
passphrase, pErr := promptPassphrase()
if pErr != nil {
fmt.Printf("Error: %v\n", pErr)
os.Exit(1)
}
// Expose the passphrase to credential.PassphraseProvider (which calls
// os.Getenv by default) so that SaveConfig can encrypt api_keys.
// This process is a one-shot CLI tool; the env var is never exposed outside
// the current process and disappears when it exits.
os.Setenv(credential.PassphraseEnvVar, passphrase)
if err = setupSSHKey(); err != nil {
fmt.Printf("Error generating SSH key: %v\n", err)
os.Exit(1)
}
}
var cfg *config.Config
if configExists {
// Preserve the existing config; SaveConfig will re-encrypt api_keys with the new passphrase.
cfg, err = config.LoadConfig(configPath)
if err != nil {
fmt.Printf("Error loading existing config: %v\n", err)
os.Exit(1)
}
} else {
cfg = config.DefaultConfig()
}
if err := config.SaveConfig(configPath, cfg); err != nil {
fmt.Printf("Error saving config: %v\n", err)
os.Exit(1)
@ -33,9 +79,17 @@ func onboard() {
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
fmt.Printf("%s picoclaw is ready!\n", internal.Logo)
fmt.Printf("\n%s picoclaw is ready!\n", internal.Logo)
fmt.Println("\nNext steps:")
if encrypt {
fmt.Println(" 1. Set your encryption passphrase before starting picoclaw:")
fmt.Println(" export PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Linux/macOS")
fmt.Println(" set PICOCLAW_KEY_PASSPHRASE=<your-passphrase> # Windows cmd")
fmt.Println("")
fmt.Println(" 2. Add your API key to", configPath)
} else {
fmt.Println(" 1. Add your API key to", configPath)
}
fmt.Println("")
fmt.Println(" Recommended:")
fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
@ -43,7 +97,66 @@ func onboard() {
fmt.Println("")
fmt.Println(" See README.md for 17+ supported providers.")
fmt.Println("")
if encrypt {
fmt.Println(" 3. Chat: picoclaw agent -m \"Hello!\"")
} else {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
}
// promptPassphrase reads the encryption passphrase twice from the terminal
// (with echo disabled) and returns it. Returns an error if the passphrase is
// empty or if the two inputs do not match.
func promptPassphrase() (string, error) {
fmt.Print("Enter passphrase for credential encryption: ")
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("reading passphrase: %w", err)
}
if len(p1) == 0 {
return "", fmt.Errorf("passphrase must not be empty")
}
fmt.Print("Confirm passphrase: ")
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", fmt.Errorf("reading passphrase confirmation: %w", err)
}
if string(p1) != string(p2) {
return "", fmt.Errorf("passphrases do not match")
}
return string(p1), nil
}
// setupSSHKey generates the picoclaw-specific SSH key at ~/.ssh/picoclaw_ed25519.key.
// If the key already exists the user is warned and asked to confirm overwrite.
// Answering anything other than "y" keeps the existing key (not an error).
func setupSSHKey() error {
keyPath, err := credential.DefaultSSHKeyPath()
if err != nil {
return fmt.Errorf("cannot determine SSH key path: %w", err)
}
if _, err := os.Stat(keyPath); err == nil {
fmt.Printf("\n⚠ WARNING: %s already exists.\n", keyPath)
fmt.Println(" Overwriting will invalidate any credentials previously encrypted with this key.")
fmt.Print(" Overwrite? (y/n): ")
var response string
fmt.Scanln(&response)
if response != "y" {
fmt.Println("Keeping existing SSH key.")
return nil
}
}
if err := credential.GenerateSSHKey(keyPath); err != nil {
return err
}
fmt.Printf("SSH key generated: %s\n", keyPath)
return nil
}
func createWorkspaceTemplates(workspace string) {

View file

@ -6,20 +6,32 @@ import (
"testing"
)
func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) {
func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) {
targetDir := t.TempDir()
if err := copyEmbeddedToTarget(targetDir); err != nil {
t.Fatalf("copyEmbeddedToTarget() error = %v", err)
}
agentsPath := filepath.Join(targetDir, "AGENTS.md")
if _, err := os.Stat(agentsPath); err != nil {
t.Fatalf("expected %s to exist: %v", agentsPath, err)
agentPath := filepath.Join(targetDir, "AGENT.md")
if _, err := os.Stat(agentPath); err != nil {
t.Fatalf("expected %s to exist: %v", agentPath, err)
}
legacyPath := filepath.Join(targetDir, "AGENT.md")
soulPath := filepath.Join(targetDir, "SOUL.md")
if _, err := os.Stat(soulPath); err != nil {
t.Fatalf("expected %s to exist: %v", soulPath, err)
}
userPath := filepath.Join(targetDir, "USER.md")
if _, err := os.Stat(userPath); err != nil {
t.Fatalf("expected %s to exist: %v", userPath, err)
}
for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} {
legacyPath := filepath.Join(targetDir, legacyName)
if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err)
}
}
}

View file

@ -29,7 +29,15 @@ func NewSkillsCommand() *cobra.Command {
}
d.workspace = cfg.WorkspacePath()
d.installer = skills.NewSkillInstaller(d.workspace)
installer, err := skills.NewSkillInstaller(
d.workspace,
cfg.Tools.Skills.Github.Token.String(),
cfg.Tools.Skills.Github.Proxy,
)
if err != nil {
return fmt.Errorf("error creating skills installer: %w", err)
}
d.installer = installer
// get global config directory and builtin skills directory
globalDir := filepath.Dir(internal.GetConfigPath())

View file

@ -64,9 +64,20 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er
fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
registry := registryMgr.GetRegistry(registryName)
@ -226,9 +237,20 @@ func skillsSearchCmd(query string) {
return
}
clawHubConfig := cfg.Tools.Skills.Registries.ClawHub
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
ClawHub: skills.ClawHubConfig{
Enabled: clawHubConfig.Enabled,
BaseURL: clawHubConfig.BaseURL,
AuthToken: clawHubConfig.AuthToken.String(),
SearchPath: clawHubConfig.SearchPath,
SkillsPath: clawHubConfig.SkillsPath,
DownloadPath: clawHubConfig.DownloadPath,
Timeout: clawHubConfig.Timeout,
MaxZipSize: clawHubConfig.MaxZipSize,
MaxResponseSize: clawHubConfig.MaxResponseSize,
},
})
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)

View file

@ -6,6 +6,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
)
func statusCmd() {
@ -18,8 +19,8 @@ func statusCmd() {
configPath := internal.GetConfigPath()
fmt.Printf("%s picoclaw Status\n", internal.Logo)
fmt.Printf("Version: %s\n", internal.FormatVersion())
build, _ := internal.FormatBuildInfo()
fmt.Printf("Version: %s\n", config.FormatVersion())
build, _ := config.FormatBuildInfo()
if build != "" {
fmt.Printf("Build: %s\n", build)
}
@ -41,48 +42,6 @@ func statusCmd() {
if _, err := os.Stat(configPath); err == nil {
fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
hasGemini := cfg.Providers.Gemini.APIKey != ""
hasZhipu := cfg.Providers.Zhipu.APIKey != ""
hasQwen := cfg.Providers.Qwen.APIKey != ""
hasGroq := cfg.Providers.Groq.APIKey != ""
hasVLLM := cfg.Providers.VLLM.APIBase != ""
hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
hasNvidia := cfg.Providers.Nvidia.APIKey != ""
hasOllama := cfg.Providers.Ollama.APIBase != ""
status := func(enabled bool) string {
if enabled {
return "✓"
}
return "not set"
}
fmt.Println("OpenRouter API:", status(hasOpenRouter))
fmt.Println("Anthropic API:", status(hasAnthropic))
fmt.Println("OpenAI API:", status(hasOpenAI))
fmt.Println("Gemini API:", status(hasGemini))
fmt.Println("Zhipu API:", status(hasZhipu))
fmt.Println("Qwen API:", status(hasQwen))
fmt.Println("Groq API:", status(hasGroq))
fmt.Println("Moonshot API:", status(hasMoonshot))
fmt.Println("DeepSeek API:", status(hasDeepSeek))
fmt.Println("VolcEngine API:", status(hasVolcEngine))
fmt.Println("Nvidia API:", status(hasNvidia))
if hasVLLM {
fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
} else {
fmt.Println("vLLM/Local: not set")
}
if hasOllama {
fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
} else {
fmt.Println("Ollama: not set")
}
store, _ := auth.LoadStore()
if store != nil && len(store.Credentials) > 0 {
fmt.Println("\nOAuth/Token Auth:")

View file

@ -6,6 +6,7 @@ import (
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
func NewVersionCommand() *cobra.Command {
@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command {
}
func printVersion() {
fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
build, goVer := internal.FormatBuildInfo()
fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
build, goVer := config.FormatBuildInfo()
if build != "" {
fmt.Printf(" Build: %s\n", build)
}

View file

@ -9,6 +9,7 @@ package main
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
@ -18,14 +19,17 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/cron"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/gateway"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/migrate"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/model"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/onboard"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/updater"
)
func NewPicoclawCommand() *cobra.Command {
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
cmd := &cobra.Command{
Use: "picoclaw",
@ -42,6 +46,8 @@ func NewPicoclawCommand() *cobra.Command {
cron.NewCronCommand(),
migrate.NewMigrateCommand(),
skills.NewSkillsCommand(),
model.NewModelCommand(),
updater.NewUpdateCommand("picoclaw"),
version.NewVersionCommand(),
)
@ -63,6 +69,21 @@ const (
func main() {
fmt.Printf("%s", banner)
tz_env := os.Getenv("TZ")
if tz_env != "" {
fmt.Println("TZ environment:", tz_env)
zoneinfo_env := os.Getenv("ZONEINFO")
fmt.Println("ZONEINFO environment:", zoneinfo_env)
loc, err := time.LoadLocation(tz_env)
if err != nil {
fmt.Println("Error loading time zone:", err)
} else {
fmt.Println("Time zone loaded successfully:", loc)
time.Local = loc //nolint:gosmopolitan // We intentionally set local timezone from TZ env
}
}
cmd := NewPicoclawCommand()
if err := cmd.Execute(); err != nil {
os.Exit(1)

View file

@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewPicoclawCommand(t *testing.T) {
@ -16,7 +17,7 @@ func TestNewPicoclawCommand(t *testing.T) {
require.NotNil(t, cmd)
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
short := fmt.Sprintf("%s picoclaw - Personal AI Assistant %s\n\n", internal.Logo, config.GetVersion())
assert.Equal(t, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short)
@ -38,9 +39,11 @@ func TestNewPicoclawCommand(t *testing.T) {
"cron",
"gateway",
"migrate",
"model",
"onboard",
"skills",
"status",
"update",
"version",
}

View file

@ -3,18 +3,24 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model_name": "gpt4",
"model_name": "gpt-5.4",
"max_tokens": 8192,
"context_window": 131072,
"temperature": 0.7,
"max_tool_iterations": 20,
"summarize_message_threshold": 20,
"summarize_token_percent": 75
"summarize_token_percent": 75,
"split_on_marker": false,
"tool_feedback": {
"enabled": false,
"max_args_length": 300
}
}
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
},
@ -25,6 +31,13 @@
"api_base": "https://api.anthropic.com/v1",
"thinking_level": "high"
},
{
"_comment": "Anthropic Messages API - use native format for direct Anthropic API access",
"model_name": "claude-opus-4-6",
"model": "anthropic-messages/claude-opus-4-6",
"api_key": "sk-ant-your-key",
"api_base": "https://api.anthropic.com"
},
{
"model_name": "gemini",
"model": "antigravity/gemini-2.0-flash",
@ -36,14 +49,40 @@
"api_key": "sk-your-deepseek-key"
},
{
"model_name": "loadbalanced-gpt4",
"model": "openai/gpt-5.2",
"model_name": "venice-uncensored",
"model": "venice/venice-uncensored",
"api_key": "your-venice-api-key"
},
{
"model_name": "lmstudio-local",
"model": "lmstudio/openai/gpt-oss-20b"
},
{
"model_name": "longcat",
"model": "longcat/LongCat-Flash-Thinking",
"api_key": "your-longcat-api-key"
},
{
"model_name": "modelscope-qwen",
"model": "modelscope/Qwen/Qwen3-235B-A22B-Instruct-2507",
"api_key": "your-modelscope-access-token",
"api_base": "https://api-inference.modelscope.cn/v1"
},
{
"model_name": "azure-gpt5",
"model": "azure/my-gpt5-deployment",
"api_key": "your-azure-api-key",
"api_base": "https://your-resource.openai.azure.com"
},
{
"model_name": "loadbalanced-gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-key1",
"api_base": "https://api1.example.com/v1"
},
{
"model_name": "loadbalanced-gpt4",
"model": "openai/gpt-5.2",
"model_name": "loadbalanced-gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
}
@ -54,10 +93,12 @@
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"base_url": "",
"proxy": "",
"allow_from": [
"YOUR_USER_ID"
],
"reasoning_channel_id": ""
"allow_from": ["YOUR_USER_ID"],
"use_markdown_v2": false,
"reasoning_channel_id": "",
"streaming": {
"enabled": true
}
},
"discord": {
"enabled": false,
@ -98,8 +139,13 @@
"encrypt_key": "",
"verification_token": "",
"allow_from": [],
"placeholder": {
"enabled": true,
"text": ["Thinking...", "Processing...", "Typing..."]
},
"reasoning_channel_id": "",
"random_reaction_emoji": []
"random_reaction_emoji": [],
"is_lark": false
},
"dingtalk": {
"enabled": false,
@ -128,9 +174,11 @@
},
"placeholder": {
"enabled": true,
"text": "Thinking... 💭"
"text": ["Thinking...", "Processing...", "Typing..."]
},
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"crypto_database_path": "",
"crypto_passphrase": "YOUR_MATRIX_CRYPTO_PICKLE_KEY"
},
"line": {
"enabled": false,
@ -150,38 +198,33 @@
"reasoning_channel_id": ""
},
"wecom": {
"_comment": "WeCom Bot - Easier setup, supports group chats",
"_comment": "WeCom AI Bot over WebSocket.",
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
"webhook_path": "/webhook/wecom",
"bot_id": "YOUR_BOT_ID",
"secret": "YOUR_SECRET",
"websocket_url": "wss://openws.work.weixin.qq.com",
"send_thinking_message": true,
"allow_from": [],
"reply_timeout": 5,
"reasoning_channel_id": ""
},
"wecom_app": {
"_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only.",
"pico": {
"enabled": false,
"corp_id": "YOUR_CORP_ID",
"corp_secret": "YOUR_CORP_SECRET",
"agent_id": 1000002,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-app",
"allow_from": [],
"reply_timeout": 5,
"reasoning_channel_id": ""
"token": "YOUR_PICO_TOKEN",
"allow_token_query": false,
"allow_origins": [],
"ping_interval": 30,
"read_timeout": 60,
"max_connections": 100,
"allow_from": []
},
"wecom_aibot": {
"_comment": "WeCom AI Bot (智能机器人) - Official WeCom AI Bot integration, supports proactive messaging and private chats.",
"pico_client": {
"enabled": false,
"token": "YOUR_TOKEN",
"encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
"webhook_path": "/webhook/wecom-aibot",
"max_steps": 10,
"welcome_message": "Hello! I'm your AI assistant. How can I help you today?",
"reasoning_channel_id": ""
"url": "wss://remote-pico-server/pico/ws",
"token": "YOUR_PICO_TOKEN",
"session_id": "",
"ping_interval": 30,
"read_timeout": 60,
"allow_from": []
},
"irc": {
"enabled": false,
@ -194,13 +237,8 @@
"nickserv_password": "",
"sasl_user": "",
"sasl_password": "",
"channels": [
"#mychannel"
],
"request_caps": [
"server-time",
"message-tags"
],
"channels": ["#mychannel"],
"request_caps": ["server-time", "message-tags"],
"allow_from": [],
"group_trigger": {
"mention_only": true
@ -211,79 +249,18 @@
"reasoning_channel_id": ""
}
},
"providers": {
"_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version",
"anthropic": {
"api_key": "",
"api_base": ""
},
"openai": {
"api_key": "",
"api_base": "",
"web_search": true
},
"openrouter": {
"api_key": "sk-or-v1-xxx",
"api_base": ""
},
"groq": {
"api_key": "gsk_xxx",
"api_base": ""
},
"zhipu": {
"api_key": "YOUR_ZHIPU_API_KEY",
"api_base": ""
},
"gemini": {
"api_key": "",
"api_base": ""
},
"vllm": {
"api_key": "",
"api_base": ""
},
"nvidia": {
"api_key": "nvapi-xxx",
"api_base": "",
"proxy": "http://127.0.0.1:7890"
},
"moonshot": {
"api_key": "sk-xxx",
"api_base": ""
},
"qwen": {
"api_key": "sk-xxx",
"api_base": ""
},
"ollama": {
"api_key": "",
"api_base": "http://localhost:11434/v1"
},
"cerebras": {
"api_key": "",
"api_base": ""
},
"volcengine": {
"api_key": "",
"api_base": ""
},
"mistral": {
"api_key": "",
"api_base": "https://api.mistral.ai/v1"
},
"avian": {
"api_key": "",
"api_base": "https://api.avian.io/v1"
}
},
"tools": {
"allow_read_paths": null,
"allow_write_paths": null,
"web": {
"enabled": true,
"prefer_native": true,
"fetch_limit_bytes": 10485760,
"format": "plaintext",
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"api_keys": ["YOUR_BRAVE_API_KEY"],
"max_results": 5
},
"tavily": {
@ -298,7 +275,8 @@
},
"perplexity": {
"enabled": false,
"api_key": "",
"api_key": "pplx-xxx",
"api_keys": ["pplx-xxx"],
"max_results": 5
},
"searxng": {
@ -313,7 +291,14 @@
"search_engine": "search_std",
"max_results": 5
},
"fetch_limit_bytes": 10485760
"baidu_search": {
"enabled": false,
"api_key": "",
"base_url": "https://qianfan.baidubce.com/v2/ai_search/web_search",
"max_results": 10
},
"fetch_limit_bytes": 10485760,
"private_host_whitelist": []
},
"cron": {
"enabled": true,
@ -340,19 +325,12 @@
"filesystem": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp"
]
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
},
"github": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"args": ["-y", "@modelcontextprotocol/server-github"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
}
@ -360,10 +338,7 @@
"brave-search": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-brave-search"
],
"args": ["-y", "@modelcontextprotocol/server-brave-search"],
"env": {
"BRAVE_API_KEY": "YOUR_BRAVE_API_KEY"
}
@ -380,10 +355,7 @@
"slack": {
"enabled": false,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-slack"
],
"args": ["-y", "@modelcontextprotocol/server-slack"],
"env": {
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
@ -412,6 +384,10 @@
"max_response_size": 0
}
},
"github": {
"proxy": "http://127.0.0.1:7891",
"token": ""
},
"max_concurrent_searches": 2,
"search_cache": {
"max_size": 50,
@ -445,7 +421,11 @@
"enabled": true
},
"read_file": {
"enabled": true
"enabled": true,
"mode": "bytes"
},
"send_tts": {
"enabled": false
},
"spawn": {
"enabled": true
@ -471,8 +451,23 @@
"enabled": false,
"monitor_usb": true
},
"voice": {
"model_name": "",
"echo_transcription": false
},
"hooks": {
"enabled": true,
"defaults": {
"observer_timeout_ms": 500,
"interceptor_timeout_ms": 5000,
"approval_timeout_ms": 60000
}
},
"gateway": {
"_comment": "Default log level is set to 'fatal'. Other available options are 'debug', 'info', 'warn' and 'error'.",
"host": "127.0.0.1",
"port": 18790
"port": 18790,
"hot_reload": false,
"log_level": "fatal"
}
}

View file

@ -0,0 +1,12 @@
FROM alpine:3.21
ARG TARGETPLATFORM
RUN apk add --no-cache ca-certificates tzdata
COPY $TARGETPLATFORM/picoclaw /usr/local/bin/picoclaw
COPY $TARGETPLATFORM/picoclaw-launcher /usr/local/bin/picoclaw-launcher
COPY $TARGETPLATFORM/picoclaw-launcher-tui /usr/local/bin/picoclaw-launcher-tui
ENTRYPOINT ["picoclaw-launcher"]
CMD ["-public", "-no-browser"]

67
docker/Dockerfile.heavy Normal file
View file

@ -0,0 +1,67 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
FROM golang:1.26.0-alpine AS builder
RUN apk add --no-cache git make
WORKDIR /src
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN make build
# ============================================================
# Stage 2: Node.js runtime with Python + MCP support
# ============================================================
FROM node:24-alpine3.23
RUN apk add --no-cache \
ca-certificates \
curl \
git \
python3 \
py3-pip \
chromium \
jq
# Install Playwright browsers for agent-browser
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers
RUN npm install -g agent-browser && \
npx playwright install chromium && \
chmod -R o+rx $PLAYWRIGHT_BROWSERS_PATH
# Install uv
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
ln -s /root/.local/bin/uv /usr/local/bin/uv && \
ln -s /root/.local/bin/uvx /usr/local/bin/uvx && \
uv --version
# 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
# Reuse existing node user (UID/GID 1000) — rename to picoclaw
RUN deluser node 2>/dev/null; delgroup node 2>/dev/null; \
addgroup -g 1000 picoclaw 2>/dev/null; \
adduser -D -u 1000 -G picoclaw -h /home/picoclaw picoclaw 2>/dev/null || true
USER picoclaw
# Run onboard to create initial directories and config
RUN /usr/local/bin/picoclaw onboard
# Copy default workspace
COPY --chown=picoclaw:picoclaw workspace/ /home/picoclaw/.picoclaw/workspace/
VOLUME /home/picoclaw/.picoclaw/workspace
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]

View file

@ -19,12 +19,12 @@ services:
# ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot)
# docker compose -f docker/docker-compose.yml up picoclaw-gateway
# docker compose -f docker/docker-compose.yml --profile gateway up
# ─────────────────────────────────────────────
picoclaw-gateway:
image: docker.io/sipeed/picoclaw:latest
container_name: picoclaw-gateway
restart: on-failure
restart: unless-stopped
profiles:
- gateway
# Uncomment to access host network; leave commented unless needed.
@ -32,3 +32,21 @@ services:
# - "host.docker.internal:host-gateway"
volumes:
- ./data:/root/.picoclaw
# ─────────────────────────────────────────────
# PicoClaw Launcher (Web Console + Gateway)
# docker compose -f docker/docker-compose.yml --profile launcher up
# ─────────────────────────────────────────────
picoclaw-launcher:
image: docker.io/sipeed/picoclaw:launcher
container_name: picoclaw-launcher
restart: unless-stopped
profiles:
- launcher
environment:
- PICOCLAW_GATEWAY_HOST=0.0.0.0
ports:
- "127.0.0.1:18800:18800"
- "127.0.0.1:18790:18790"
volumes:
- ./data:/root/.picoclaw

View file

@ -438,7 +438,7 @@ type ProviderAuthResult = {
### 1. Required Environment/Dependencies
- Go ≥ 1.21
- Go ≥ 1.25
- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
- `crypto` and `net/http` standard library packages
@ -584,7 +584,7 @@ Each SSE message (`data: {...}`) is wrapped in a `response` field:
],
"agents": {
"defaults": {
"model": "gemini-flash"
"model_name": "gemini-flash"
}
}
}
@ -674,7 +674,7 @@ Add a default entry in `pkg/config/defaults.go`:
#### 5. Add Auth Support (Optional)
If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:
If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/internal/auth/helpers.go`:
```go
case "your-provider":
@ -736,7 +736,7 @@ export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/m
- `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
- `pkg/providers/factory.go` - Provider factory and protocol routing
- `pkg/providers/types.go` - Provider interface definitions
- `cmd/picoclaw/cmd_auth.go` - Auth CLI commands
- `cmd/picoclaw/internal/auth/helpers.go` - Auth CLI commands
- **Documentation:**
- `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide

View file

@ -0,0 +1,164 @@
# Context
## What this document covers
This document makes explicit the boundaries of context management in the agent loop:
- what fills the context window and how space is divided
- what is stored in session history vs. built at request time
- when and how context compression happens
- how token budgets are estimated
These are existing concepts. This document clarifies their boundaries rather than introducing new ones.
---
## Context window regions
The context window is the model's total input capacity. Four regions fill it:
| Region | Assembled by | Stored in session? |
|---|---|---|
| System prompt | `BuildMessages()` — static + dynamic parts | No |
| Summary | `SetSummary()` stores it; `BuildMessages()` injects it | Separate from history |
| Session history | User / assistant / tool messages | Yes |
| Tool definitions | Provider adapter injects at call time | No |
`MaxTokens` (the output generation limit) must also be reserved from the total budget.
The available space for history is therefore:
```
history_budget = ContextWindow - system_prompt - summary - tool_definitions - MaxTokens
```
---
## ContextWindow vs MaxTokens
These serve different purposes:
- **MaxTokens** — maximum tokens the LLM may generate in one response. Sent as the `max_tokens` request parameter.
- **ContextWindow** — the model's total input context capacity.
These were previously set to the same value, which caused the summarization threshold to fire either far too early (at the default 32K) or not at all (when a user raised `max_tokens`).
Current default when not explicitly configured: `ContextWindow = MaxTokens * 4`.
---
## Session history
Session history stores only conversation messages:
- `user` — user input
- `assistant` — LLM response (may include `ToolCalls`)
- `tool` — tool execution results
Session history does **not** contain:
- System prompts — assembled at request time by `BuildMessages`
- Summary content — stored separately via `SetSummary`, injected by `BuildMessages`
This distinction matters: any code that operates on session history — compression, boundary detection, token estimation — must not assume a system message is present.
---
## Turn
A **Turn** is one complete cycle:
> user message -> LLM iterations (possibly including tool calls) -> final assistant response
This definition comes from the agent loop design (#1316). In session history, Turn boundaries are identified by `user`-role messages.
Turn is the atomic unit for compression. Cutting inside a Turn can orphan tool-call sequences — an assistant message with `ToolCalls` separated from its corresponding `tool` results. Compressing at Turn boundaries avoids this by construction.
`parseTurnBoundaries(history)` returns the starting index of each Turn.
`findSafeBoundary(history, targetIndex)` snaps a target cut point to the nearest Turn boundary.
---
## Compression paths
Three compression paths exist, in order of preference:
### 1. Async summarization
`maybeSummarize` runs after each Turn completes.
Triggers when message count exceeds a threshold, or when estimated history tokens exceed a percentage of `ContextWindow`. If triggered, a background goroutine calls the LLM to produce a summary of the oldest messages. The summary is stored via `SetSummary`; `BuildMessages` injects it into the system prompt on the next call.
Cut point uses `findSafeBoundary` so no Turn is split.
### 2. Proactive budget check
`isOverContextBudget` runs before each LLM call.
Uses the full budget formula: `message_tokens + tool_def_tokens + MaxTokens > ContextWindow`. If over budget, triggers `forceCompression` and rebuilds messages before calling the LLM.
This prevents wasted (and billed) LLM calls that would otherwise fail with a context-window error.
### 3. Emergency compression (reactive)
`forceCompression` runs when the LLM returns a context-window error despite the proactive check.
Drops the oldest ~50% of Turns. If the history is a single Turn with no safe split point (e.g. one user message followed by a massive tool response), falls back to keeping only the most recent user message — breaking Turn atomicity as a last resort to avoid a context-exceeded loop.
Stores a compression note in the session summary (not in history messages) so `BuildMessages` can include it in the next system prompt.
This is the fallback for when the token estimate undershoots reality.
---
## Token estimation
Estimation uses a heuristic of ~2.5 characters per token (`chars * 2 / 5`).
`estimateMessageTokens` counts:
- `Content` (rune count, for multibyte correctness)
- `ReasoningContent` (extended thinking / chain-of-thought)
- `ToolCalls` — ID, type, function name, arguments
- `ToolCallID` (tool result metadata)
- Per-message overhead (role label, JSON structure)
- `Media` items — flat per-item token estimate, added directly to the final count (not through the character heuristic, since actual cost depends on resolution and provider-specific image tokenization)
`estimateToolDefsTokens` counts tool definition overhead: name, description, JSON schema of parameters.
These are deliberately heuristic. The proactive check handles the common case; the reactive path catches estimation errors.
---
## Interface boundaries
Context budget functions (`parseTurnBoundaries`, `findSafeBoundary`, `estimateMessageTokens`, `isOverContextBudget`) are **pure functions**. They take `[]providers.Message` and integer parameters. They have no dependency on `AgentLoop` or any other runtime struct.
`BuildMessages` is the sole assembler of the final message array sent to the LLM. Budget functions inform compression decisions but do not construct messages.
`forceCompression` and `summarizeSession` mutate session state (history and summary). `BuildMessages` reads that state to construct context. The flow is:
```
budget check --> compression decision --> mutate session --> BuildMessages reads session --> LLM call
```
---
## Known gaps
These are recognized limitations in the current implementation, documented here for visibility:
- **Summarization trigger does not use the full budget formula.** `maybeSummarize` compares estimated history tokens against a percentage of `ContextWindow`. It does not account for system prompt size, tool definition overhead, or `MaxTokens` reserve. The proactive check covers the critical path (preventing 400 errors), but the summarization trigger could be aligned with the same budget model for more accurate early compression.
- **Token estimation is heuristic.** It does not account for provider-specific tokenization, exact system prompt size (assembled separately), or variable image token costs. The two-path design (proactive + reactive) is intended to tolerate this imprecision.
- **Reactive retry does not preserve media.** When the reactive path rebuilds context after compression, it currently passes empty values for media references. This is a pre-existing issue in the main loop, not introduced by the budget system.
---
## What this document does not cover
- How `AGENT.md` frontmatter configures context parameters — that is part of the Agent definition work
- How the context builder assembles context in the new architecture — that is upcoming work
- How compression events surface through the event system — that is part of the event model (#1316)
- Subagent context isolation — that is a separate track

View file

@ -0,0 +1,35 @@
> Retour au [README](../../../README.fr.md)
# DingTalk
DingTalk est la plateforme de communication d'entreprise d'Alibaba, très populaire dans les milieux professionnels chinois. Elle utilise un SDK de streaming pour maintenir des connexions persistantes.
## Configuration
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
}
}
}
```
| Champ | Type | Requis | Description |
| ------------- | ------ | ------ | ---------------------------------------------------------------- |
| enabled | bool | Oui | Activer ou non le canal DingTalk |
| client_id | string | Oui | Client ID de l'application DingTalk |
| client_secret | string | Oui | Client Secret de l'application DingTalk |
| allow_from | array | Non | Liste blanche d'ID utilisateurs ; vide signifie tous les utilisateurs |
## Procédure de configuration
1. Rendez-vous sur la [plateforme ouverte DingTalk](https://open.dingtalk.com/)
2. Créez une application interne d'entreprise
3. Obtenez le Client ID et le Client Secret depuis les paramètres de l'application
4. Configurez OAuth et les abonnements aux événements (si nécessaire)
5. Renseignez le Client ID et le Client Secret dans le fichier de configuration

View file

@ -0,0 +1,35 @@
> [README](../../../README.ja.md) に戻る
# DingTalk
DingTalkはアリババの企業向けコミュニケーションプラットフォームで、中国のビジネス環境で広く利用されています。ストリーミング SDK を使用して持続的な接続を維持します。
## 設定
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
}
}
}
```
| フィールド | 型 | 必須 | 説明 |
| ------------- | ------ | ---- | -------------------------------------------- |
| enabled | bool | はい | DingTalk チャンネルを有効にするかどうか |
| client_id | string | はい | DingTalk アプリケーションの Client ID |
| client_secret | string | はい | DingTalk アプリケーションの Client Secret |
| allow_from | array | いいえ | ユーザーIDのホワイトリスト。空の場合は全ユーザーを許可 |
## セットアップ手順
1. [DingTalk オープンプラットフォーム](https://open.dingtalk.com/) にアクセスする
2. 企業内部アプリケーションを作成する
3. アプリケーション設定から Client ID と Client Secret を取得する
4. OAuth とイベントサブスクリプションを設定する(必要な場合)
5. Client ID と Client Secret を設定ファイルに入力する

View file

@ -0,0 +1,35 @@
> Back to [README](../../../README.md)
# DingTalk
DingTalk is Alibaba's enterprise communication platform, widely used in Chinese workplaces. It uses a streaming SDK to maintain persistent connections.
## Configuration
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
}
}
}
```
| Field | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------------------------------------- |
| enabled | bool | Yes | Whether to enable the DingTalk channel |
| client_id | string | Yes | Client ID of the DingTalk application |
| client_secret | string | Yes | Client Secret of the DingTalk application |
| allow_from | array | No | User ID whitelist; empty means all users are allowed |
## Setup
1. Go to the [DingTalk Open Platform](https://open.dingtalk.com/)
2. Create an internal enterprise application
3. Obtain the Client ID and Client Secret from the application settings
4. Configure OAuth and event subscriptions (if needed)
5. Fill in the Client ID and Client Secret in the configuration file

View file

@ -0,0 +1,35 @@
> Voltar ao [README](../../../README.pt-br.md)
# DingTalk
DingTalk é a plataforma de comunicação empresarial da Alibaba, amplamente utilizada no ambiente corporativo chinês. Ela usa um SDK de streaming para manter conexões persistentes.
## Configuração
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
}
}
}
```
| Campo | Tipo | Obrigatório | Descrição |
| ------------- | ------ | ----------- | ---------------------------------------------------------------- |
| enabled | bool | Sim | Se o canal DingTalk deve ser habilitado |
| client_id | string | Sim | Client ID do aplicativo DingTalk |
| client_secret | string | Sim | Client Secret do aplicativo DingTalk |
| allow_from | array | Não | Lista de permissão de IDs de usuário; vazio permite todos |
## Configuração passo a passo
1. Acesse a [Plataforma Aberta DingTalk](https://open.dingtalk.com/)
2. Crie um aplicativo interno corporativo
3. Obtenha o Client ID e o Client Secret nas configurações do aplicativo
4. Configure OAuth e assinaturas de eventos (se necessário)
5. Preencha o Client ID e o Client Secret no arquivo de configuração

View file

@ -0,0 +1,35 @@
> Quay lại [README](../../../README.vi.md)
# DingTalk
DingTalk là nền tảng giao tiếp doanh nghiệp của Alibaba, được sử dụng rộng rãi trong môi trường làm việc tại Trung Quốc. Nền tảng này sử dụng SDK streaming để duy trì kết nối liên tục.
## Cấu hình
```json
{
"channels": {
"dingtalk": {
"enabled": true,
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"allow_from": []
}
}
}
```
| Trường | Kiểu | Bắt buộc | Mô tả |
| ------------- | ------ | -------- | ---------------------------------------------------------------- |
| enabled | bool | Có | Có bật kênh DingTalk hay không |
| client_id | string | Có | Client ID của ứng dụng DingTalk |
| client_secret | string | Có | Client Secret của ứng dụng DingTalk |
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống cho phép tất cả |
## Quy trình thiết lập
1. Truy cập [Nền tảng mở DingTalk](https://open.dingtalk.com/)
2. Tạo một ứng dụng nội bộ doanh nghiệp
3. Lấy Client ID và Client Secret từ cài đặt ứng dụng
4. Cấu hình OAuth và đăng ký sự kiện (nếu cần)
5. Điền Client ID và Client Secret vào file cấu hình

View file

@ -1,3 +1,5 @@
> 返回 [README](../../../README.zh.md)
# 钉钉
钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。

View file

@ -0,0 +1,39 @@
> Retour au [README](../../../README.fr.md)
# Discord
Discord est une application gratuite de chat vocal, vidéo et textuel conçue pour les communautés. PicoClaw se connecte aux serveurs Discord via l'API Bot Discord, avec prise en charge de la réception et de l'envoi de messages.
## Configuration
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
"mention_only": false
}
}
}
}
```
| Champ | Type | Requis | Description |
| ------------- | ------ | ------ | --------------------------------------------------------------------------- |
| enabled | bool | Oui | Activer ou non le canal Discord |
| token | string | Oui | Token du bot Discord |
| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
| group_trigger | object | Non | Paramètres de déclenchement de groupe (exemple : { "mention_only": false }) |
## Configuration initiale
1. Accéder au [Portail des développeurs Discord](https://discord.com/developers/applications) et créer une nouvelle application
2. Activer les Intents :
- Message Content Intent
- Server Members Intent
3. Obtenir le Token du bot
4. Renseigner le Token du bot dans le fichier de configuration
5. Inviter le bot sur le serveur et lui accorder les permissions nécessaires (ex. envoyer des messages, lire l'historique des messages)

View file

@ -0,0 +1,39 @@
> [README](../../../README.ja.md) に戻る
# Discord
Discord はコミュニティ向けに設計された無料の音声・ビデオ・テキストチャットアプリケーションです。PicoClaw は Discord Bot API を通じて Discord サーバーに接続し、メッセージの受信と送信をサポートします。
## 設定
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
"mention_only": false
}
}
}
}
```
| フィールド | 型 | 必須 | 説明 |
| ------------- | ------ | ------ | ----------------------------------------------------------------- |
| enabled | bool | はい | Discord チャンネルを有効にするかどうか |
| token | string | はい | Discord ボットトークン |
| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
| group_trigger | object | いいえ | グループトリガー設定(例: { "mention_only": false } |
## セットアップ手順
1. [Discord 開発者ポータル](https://discord.com/developers/applications) にアクセスして新しいアプリケーションを作成する
2. Intents を有効にする:
- Message Content Intent
- Server Members Intent
3. Bot トークンを取得する
4. 設定ファイルに Bot トークンを入力する
5. ボットをサーバーに招待し、必要な権限を付与する(例: メッセージの送信、メッセージ履歴の読み取りなど)

View file

@ -0,0 +1,39 @@
> Back to [README](../../../README.md)
# Discord
Discord is a free voice, video, and text chat application designed for communities. PicoClaw connects to Discord servers via the Discord Bot API, supporting both receiving and sending messages.
## Configuration
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
"mention_only": false
}
}
}
}
```
| Field | Type | Required | Description |
| ------------- | ------ | -------- | --------------------------------------------------------------------------- |
| enabled | bool | Yes | Whether to enable the Discord channel |
| token | string | Yes | Discord Bot Token |
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
| group_trigger | object | No | Group trigger settings (example: { "mention_only": false }) |
## Setup
1. Go to the [Discord Developer Portal](https://discord.com/developers/applications) and create a new application
2. Enable Intents:
- Message Content Intent
- Server Members Intent
3. Obtain the Bot Token
4. Fill in the Bot Token in the configuration file
5. Invite the bot to your server and grant the necessary permissions (e.g. Send Messages, Read Message History)

View file

@ -0,0 +1,39 @@
> Voltar ao [README](../../../README.pt-br.md)
# Discord
Discord é um aplicativo gratuito de chat de voz, vídeo e texto projetado para comunidades. O PicoClaw se conecta a servidores Discord via Discord Bot API, com suporte para receber e enviar mensagens.
## Configuração
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
"mention_only": false
}
}
}
}
```
| Campo | Tipo | Obrigatório | Descrição |
| ------------- | ------ | ----------- | --------------------------------------------------------------------------- |
| enabled | bool | Sim | Se o canal Discord deve ser habilitado |
| token | string | Sim | Token do Bot Discord |
| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários |
| group_trigger | object | Não | Configurações de gatilho de grupo (exemplo: { "mention_only": false }) |
## Configuração inicial
1. Acesse o [Portal de Desenvolvedores do Discord](https://discord.com/developers/applications) e crie uma nova aplicação
2. Habilite os Intents:
- Message Content Intent
- Server Members Intent
3. Obtenha o Token do Bot
4. Preencha o Token do Bot no arquivo de configuração
5. Convide o bot para o servidor e conceda as permissões necessárias (ex. enviar mensagens, ler histórico de mensagens)

View file

@ -0,0 +1,39 @@
> Quay lại [README](../../../README.vi.md)
# Discord
Discord là ứng dụng chat thoại, video và văn bản miễn phí được thiết kế cho cộng đồng. PicoClaw kết nối với máy chủ Discord qua Discord Bot API, hỗ trợ nhận và gửi tin nhắn.
## Cấu hình
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allow_from": ["YOUR_USER_ID"],
"group_trigger": {
"mention_only": false
}
}
}
}
```
| Trường | Kiểu | Bắt buộc | Mô tả |
| ------------- | ------ | -------- | --------------------------------------------------------------------------- |
| enabled | bool | Có | Có bật kênh Discord hay không |
| token | string | Có | Token Bot Discord |
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả |
| group_trigger | object | Không | Cài đặt kích hoạt nhóm (ví dụ: { "mention_only": false }) |
## Hướng dẫn thiết lập
1. Truy cập [Discord Developer Portal](https://discord.com/developers/applications) và tạo ứng dụng mới
2. Bật các Intents:
- Message Content Intent
- Server Members Intent
3. Lấy Bot Token
4. Điền Bot Token vào file cấu hình
5. Mời bot vào máy chủ và cấp các quyền cần thiết (ví dụ: gửi tin nhắn, đọc lịch sử tin nhắn)

View file

@ -1,3 +1,5 @@
> 返回 [README](../../../README.zh.md)
# Discord
Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。

View file

@ -0,0 +1,52 @@
> Retour au [README](../../../README.fr.md)
# Feishu
Feishu (nom international : Lark) est une plateforme de collaboration d'entreprise de ByteDance. Elle prend en charge les marchés chinois et mondiaux via des connexions WebSocket pilotées par événements.
## Configuration
```json
{
"channels": {
"feishu": {
"enabled": true,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
}
}
}
```
| Champ | Type | Requis | Description |
| --------------------- | ------ | ------ | --------------------------------------------------------------------------- |
| enabled | bool | Oui | Activer ou non le canal Feishu |
| app_id | string | Oui | App ID de l'application Feishu (commence par `cli_`) |
| app_secret | string | Oui | App Secret de l'application Feishu |
| encrypt_key | string | Non | Clé de chiffrement pour les callbacks d'événements |
| verification_token | string | Non | Token utilisé pour la vérification des événements Webhook |
| allow_from | array | Non | Liste blanche d'identifiants utilisateur ; vide signifie tous les utilisateurs |
| random_reaction_emoji | array | Non | Liste d'emojis de réaction aléatoires ; vide utilise le "Pin" par défaut |
## Configuration initiale
1. Accéder à la [plateforme ouverte Feishu](https://open.feishu.cn/) et créer une application
2. Activer la capacité **Bot** dans les paramètres de l'application
3. Créer une version et publier l'application (la configuration prend effet après la publication)
4. Obtenir l'**App ID** (commence par `cli_`) et l'**App Secret**
5. Renseigner l'App ID et l'App Secret dans le fichier de configuration PicoClaw
6. Exécuter `picoclaw gateway` pour démarrer le service
7. Rechercher le nom du bot dans Feishu et commencer une conversation
> PicoClaw se connecte à Feishu en mode WebSocket/SDK — aucune adresse de callback publique ni URL Webhook n'est requise.
>
> `encrypt_key` et `verification_token` sont optionnels ; l'activation du chiffrement des événements est recommandée pour les environnements de production.
>
> Pour les références d'emojis personnalisés, voir : [Liste des emojis Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## Limitations de plateforme
> ⚠️ **Le canal Feishu ne prend pas en charge les appareils 32 bits.** Le SDK Feishu ne fournit que des builds 64 bits. Les architectures 32 bits (armv6, armv7, mipsle, etc.) ne peuvent pas utiliser le canal Feishu. Pour la messagerie sur des appareils 32 bits, utilisez Telegram, Discord ou OneBot.

View file

@ -0,0 +1,52 @@
> [README](../../../README.ja.md) に戻る
# 飛書Feishu
飛書国際名Larkは ByteDance が提供するエンタープライズコラボレーションプラットフォームです。イベント駆動型の WebSocket 接続を通じて、中国および世界市場の両方をサポートします。
## 設定
```json
{
"channels": {
"feishu": {
"enabled": true,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
}
}
}
```
| フィールド | 型 | 必須 | 説明 |
| --------------------- | ------ | ------ | ----------------------------------------------------------------- |
| enabled | bool | はい | 飛書チャンネルを有効にするかどうか |
| app_id | string | はい | 飛書アプリケーションの App ID`cli_` で始まる) |
| app_secret | string | はい | 飛書アプリケーションの App Secret |
| encrypt_key | string | いいえ | イベントコールバックの暗号化キー |
| verification_token | string | いいえ | Webhook イベント検証に使用するトークン |
| allow_from | array | いいえ | 許可するユーザーIDのリスト。空の場合はすべてのユーザーを許可 |
| random_reaction_emoji | array | いいえ | ランダムに追加する絵文字のリスト。空の場合はデフォルトの "Pin" を使用 |
## セットアップ手順
1. [飛書オープンプラットフォーム](https://open.feishu.cn/) にアクセスしてアプリケーションを作成する
2. アプリケーション設定で**ボット**機能を有効にする
3. バージョンを作成してアプリケーションを公開する(公開後に設定が有効になる)
4. **App ID**`cli_` で始まる)と **App Secret** を取得する
5. PicoClaw 設定ファイルに App ID と App Secret を入力する
6. `picoclaw gateway` を実行してサービスを起動する
7. 飛書でボット名を検索して会話を始める
> PicoClaw は WebSocket/SDK モードで飛書に接続するため、公開コールバックアドレスや Webhook URL の設定は不要です。
>
> `encrypt_key``verification_token` はオプションですが、本番環境ではイベント暗号化を有効にすることを推奨します。
>
> カスタム絵文字の参考:[飛書絵文字リスト](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## プラットフォーム制限
> ⚠️ **飛書チャネルは 32 ビットデバイスをサポートしていません。** 飛書 SDK は 64 ビットビルドのみ提供しています。armv6 / armv7 / mipsle などの 32 ビットアーキテクチャでは飛書チャネルを使用できません。32 ビットデバイスでのメッセージングには、Telegram、Discord、または OneBot をご利用ください。

View file

@ -0,0 +1,52 @@
> Back to [README](../../../README.md)
# Feishu
Feishu (international name: Lark) is an enterprise collaboration platform by ByteDance. It supports both Chinese and global markets through event-driven WebSocket connections.
## Configuration
```json
{
"channels": {
"feishu": {
"enabled": true,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
}
}
}
```
| Field | Type | Required | Description |
| --------------------- | ------ | -------- | ------------------------------------------------------------------ |
| enabled | bool | Yes | Whether to enable the Feishu channel |
| app_id | string | Yes | App ID of the Feishu application (starts with `cli_`) |
| app_secret | string | Yes | App Secret of the Feishu application |
| encrypt_key | string | No | Encryption key for event callbacks |
| verification_token | string | No | Token used for Webhook event verification |
| allow_from | array | No | Allowlist of user IDs; empty means all users are allowed |
| random_reaction_emoji | array | No | List of random reaction emojis; empty uses the default "Pin" |
## Setup
1. Go to the [Feishu Open Platform](https://open.feishu.cn/) and create an application
2. Enable the **Bot** capability in the application settings
3. Create a version and publish the application (configuration takes effect only after publishing)
4. Obtain the **App ID** (starts with `cli_`) and **App Secret**
5. Fill in the App ID and App Secret in the PicoClaw configuration file
6. Run `picoclaw gateway` to start the service
7. Search for the bot name in Feishu and start a conversation
> PicoClaw connects to Feishu using WebSocket/SDK mode — no public callback address or Webhook URL is required.
>
> `encrypt_key` and `verification_token` are optional; enabling event encryption is recommended for production environments.
>
> For custom emoji references, see: [Feishu Emoji List](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## Platform Limitations
> ⚠️ **Feishu channel does not support 32-bit devices.** The Feishu SDK only provides 64-bit builds. Devices running armv6, armv7, mipsle, or other 32-bit architectures cannot use the Feishu channel. For messaging on 32-bit devices, use Telegram, Discord, or OneBot instead.

View file

@ -0,0 +1,52 @@
> Voltar ao [README](../../../README.pt-br.md)
# Feishu
Feishu (nome internacional: Lark) é uma plataforma de colaboração empresarial da ByteDance. Suporta os mercados chinês e global por meio de conexões WebSocket orientadas a eventos.
## Configuração
```json
{
"channels": {
"feishu": {
"enabled": true,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
}
}
}
```
| Campo | Tipo | Obrigatório | Descrição |
| --------------------- | ------ | ----------- | -------------------------------------------------------------------------- |
| enabled | bool | Sim | Se o canal Feishu deve ser habilitado |
| app_id | string | Sim | App ID da aplicação Feishu (começa com `cli_`) |
| app_secret | string | Sim | App Secret da aplicação Feishu |
| encrypt_key | string | Não | Chave de criptografia para callbacks de eventos |
| verification_token | string | Não | Token usado para verificação de eventos Webhook |
| allow_from | array | Não | Lista de IDs de usuários permitidos; vazio significa todos os usuários |
| random_reaction_emoji | array | Não | Lista de emojis de reação aleatórios; vazio usa o "Pin" padrão |
## Configuração inicial
1. Acesse a [Plataforma Aberta Feishu](https://open.feishu.cn/) e crie uma aplicação
2. Habilite a capacidade de **Bot** nas configurações da aplicação
3. Crie uma versão e publique a aplicação (a configuração entra em vigor após a publicação)
4. Obtenha o **App ID** (começa com `cli_`) e o **App Secret**
5. Preencha o App ID e o App Secret no arquivo de configuração do PicoClaw
6. Execute `picoclaw gateway` para iniciar o serviço
7. Pesquise o nome do bot no Feishu e inicie uma conversa
> O PicoClaw se conecta ao Feishu usando o modo WebSocket/SDK — nenhum endereço de callback público ou URL de Webhook é necessário.
>
> `encrypt_key` e `verification_token` são opcionais; recomenda-se habilitar a criptografia de eventos em ambientes de produção.
>
> Para referências de emojis personalizados, consulte: [Lista de Emojis do Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## Limitações de Plataforma
> ⚠️ **O canal Feishu não suporta dispositivos 32 bits.** O SDK do Feishu fornece apenas builds 64 bits. Arquiteturas 32 bits (armv6, armv7, mipsle, etc.) não podem usar o canal Feishu. Para mensagens em dispositivos 32 bits, use Telegram, Discord ou OneBot.

View file

@ -0,0 +1,52 @@
> Quay lại [README](../../../README.vi.md)
# Feishu
Feishu (tên quốc tế: Lark) là nền tảng cộng tác doanh nghiệp của ByteDance. Hỗ trợ cả thị trường Trung Quốc và toàn cầu thông qua kết nối WebSocket theo hướng sự kiện.
## Cấu hình
```json
{
"channels": {
"feishu": {
"enabled": true,
"app_id": "cli_xxx",
"app_secret": "xxx",
"encrypt_key": "",
"verification_token": "",
"allow_from": []
}
}
}
```
| Trường | Kiểu | Bắt buộc | Mô tả |
| --------------------- | ------ | -------- | ------------------------------------------------------------------------ |
| enabled | bool | Có | Có bật kênh Feishu hay không |
| app_id | string | Có | App ID của ứng dụng Feishu (bắt đầu bằng `cli_`) |
| app_secret | string | Có | App Secret của ứng dụng Feishu |
| encrypt_key | string | Không | Khóa mã hóa cho callback sự kiện |
| verification_token | string | Không | Token dùng để xác minh sự kiện Webhook |
| allow_from | array | Không | Danh sách trắng ID người dùng; để trống nghĩa là cho phép tất cả |
| random_reaction_emoji | array | Không | Danh sách emoji phản ứng ngẫu nhiên; để trống dùng "Pin" mặc định |
## Hướng dẫn thiết lập
1. Truy cập [Nền tảng Mở Feishu](https://open.feishu.cn/) và tạo ứng dụng
2. Bật khả năng **Bot** trong cài đặt ứng dụng
3. Tạo phiên bản và xuất bản ứng dụng (cấu hình có hiệu lực sau khi xuất bản)
4. Lấy **App ID** (bắt đầu bằng `cli_`) và **App Secret**
5. Điền App ID và App Secret vào file cấu hình PicoClaw
6. Chạy `picoclaw gateway` để khởi động dịch vụ
7. Tìm kiếm tên bot trong Feishu và bắt đầu trò chuyện
> PicoClaw kết nối với Feishu bằng chế độ WebSocket/SDK — không cần cấu hình địa chỉ callback công khai hay Webhook URL.
>
> `encrypt_key``verification_token` là tùy chọn; nên bật mã hóa sự kiện trong môi trường sản xuất.
>
> Tham khảo emoji tùy chỉnh: [Danh sách Emoji Feishu](https://open.larkoffice.com/document/server-docs/im-v1/message-reaction/emojis-introduce)
## Giới hạn nền tảng
> ⚠️ **Kênh Feishu không hỗ trợ thiết bị 32 bit.** SDK Feishu chỉ cung cấp bản build 64 bit. Các kiến trúc 32 bit (armv6, armv7, mipsle, v.v.) không thể sử dụng kênh Feishu. Để nhắn tin trên thiết bị 32 bit, hãy dùng Telegram, Discord hoặc OneBot.

Some files were not shown because too many files have changed in this diff Show more