Merge pull request #31 from dj-oyu/refactor/upstream-compat

refactor: upstream merge + hook-based fork isolation
This commit is contained in:
dj-oyu 2026-03-13 14:56:54 +09:00 committed by GitHub
commit 87f9e6b255
377 changed files with 51667 additions and 18898 deletions

138
.github/workflows/nightly.yml vendored Normal file
View file

@ -0,0 +1,138 @@
name: Nightly Build
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
contents: read
jobs:
nightly:
name: Nightly Build
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0
- 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
VERSION="v0.0.0-nightly.${DATE}.${SHA}"
else
VERSION="${BASE_VERSION}-nightly.${DATE}.${SHA}"
fi
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}...main"
fi
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "changelog=**Full Changelog**: $COMPARE_URL" >> "$GITHUB_OUTPUT"
- name: Setup Go from go.mod
id: setup-go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
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
with:
distribution: goreleaser
version: ~> v2
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
GORELEASER_CURRENT_TAG: ${{ steps.version.outputs.version }}
NIGHTLY_BUILD: "true"
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
- name: Update nightly release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANGELOG='${{ steps.version.outputs.changelog }}'
NOTES=$(cat <<EOF
Nightly build for **${VERSION}**
This is an automated build and may be unstable. Use with caution.
${CHANGELOG}
EOF
)
# Delete existing nightly release and tag
gh release delete nightly --cleanup-tag -y 2>/dev/null || 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 \
--latest=false \
"${ASSETS[@]}"

View file

@ -1,7 +1,7 @@
name: PR
on:
pull_request: {}
pull_request: { }
jobs:
lint:
@ -19,6 +19,9 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install frontend deps
run: cd pkg/miniapp/frontend && bun install
- name: Run go generate
run: go generate ./...
@ -61,23 +64,11 @@ jobs:
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Install frontend deps
run: cd pkg/miniapp/frontend && bun install
- name: Run go generate
run: go generate ./...
- name: Run frontend tests
run: |
pnpm --dir pkg/miniapp/frontend install --frozen-lockfile
pnpm --dir pkg/miniapp/frontend test
- name: Run go test
run: go test ./...

View file

@ -65,6 +65,14 @@ jobs:
with:
go-version-file: go.mod
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup pnpm
run: corepack enable && corepack prepare pnpm@latest --activate
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@ -96,6 +104,11 @@ jobs:
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }}
GOVERSION: ${{ steps.setup-go.outputs.go-version }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
- name: Apply release flags
shell: bash

12
.gitignore vendored
View file

@ -47,14 +47,12 @@ docs/plans/
# Added by goreleaser init:
dist/
!pkg/miniapp/static/dist/
!pkg/miniapp/static/dist/**
*.vite/
# Windows Application Icon/Resource
*.syso
# Frontend dependencies
node_modules/
# Keep embedded backend dist directory placeholder in VCS
!web/backend/dist/
web/backend/dist/*
!web/backend/dist/.gitkeep

View file

@ -6,8 +6,9 @@ before:
hooks:
- go mod tidy
- go generate ./...
- sh -c 'cd web/frontend && pnpm install && pnpm build:backend'
- go install github.com/tc-hib/go-winres@latest
- go-winres make --in cmd/picoclaw-launcher/winres/winres.json --out cmd/picoclaw-launcher/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
- go-winres make --in web/backend/winres/winres.json --out web/backend/rsrc --product-version={{ .Version }} --file-version={{ .Version }}
builds:
- id: picoclaw
@ -17,10 +18,10 @@ builds:
- 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
@ -32,9 +33,13 @@ builds:
- riscv64
- loong64
- arm
- s390x
- mipsle
goarm:
- "6"
- "7"
gomips:
- softfloat
main: ./cmd/picoclaw
ignore:
- goos: windows
@ -59,10 +64,14 @@ builds:
- riscv64
- loong64
- arm
- s390x
- mipsle
goarm:
- "6"
- "7"
main: ./cmd/picoclaw-launcher
gomips:
- softfloat
main: ./web/backend
ignore:
- goos: windows
goarch: arm
@ -86,9 +95,13 @@ builds:
- riscv64
- loong64
- arm
- s390x
- mipsle
goarm:
- "6"
- "7"
gomips:
- softfloat
main: ./cmd/picoclaw-launcher-tui
ignore:
- goos: windows
@ -103,15 +116,49 @@ dockers_v2:
- picoclaw
images:
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/picoclaw"
- "docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}"
- 'docker.io/{{ .Env.DOCKERHUB_IMAGE_NAME }}'
tags:
- "{{ .Tag }}"
- "latest"
- '{{ 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" }}'
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
sign:
certificate: "{{.Env.MACOS_SIGN_P12}}"
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
notarize:
issuer_id: "{{.Env.MACOS_NOTARY_ISSUER_ID}}"
key_id: "{{.Env.MACOS_NOTARY_KEY_ID}}"
key: "{{.Env.MACOS_NOTARY_KEY}}"
wait: true
timeout: 20m
archives:
- formats: [tar.gz]
# this name template makes the OS and Arch compatible with the results of `uname`.
@ -129,7 +176,7 @@ archives:
nfpms:
- id: picoclaw
builds:
ids:
- picoclaw
- picoclaw-launcher
- picoclaw-launcher-tui
@ -149,6 +196,11 @@ nfpms:
- rpm
- deb
bindir: /usr/bin
contents:
- src: web/picoclaw-launcher.desktop
dst: /usr/share/applications/picoclaw-launcher.desktop
- src: web/picoclaw-launcher.png
dst: /usr/share/icons/hicolor/512x512/apps/picoclaw-launcher.png
changelog:
sort: asc
@ -163,6 +215,7 @@ changelog:
# lzma: true
release:
disable: '{{ isEnvSet "NIGHTLY_BUILD" }}'
footer: >-
---

View file

@ -11,8 +11,8 @@ 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=-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
@ -110,12 +110,18 @@ build: generate
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
@if [ "$(PLATFORM)" = "linux" ] || [ "$(PLATFORM)" = "darwin" ]; then \
echo "Install to /usr/local/bin:"; \
echo " sudo install -m 755 $(BINARY_PATH) /usr/local/bin/$(BINARY_NAME)"; \
else \
echo "Install hint skipped: /usr/local/bin command is for Linux/macOS (current: $(PLATFORM))."; \
## 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"
## build-whatsapp-native: Build with WhatsApp native (whatsmeow) support; larger binary
build-whatsapp-native: generate

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw : Assistant IA Ultra-Efficace en Go</h1>
@ -206,9 +206,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Démarrage Rapide
> [!TIP]
> Configurez votre clé API dans `~/.picoclaw/config.json`.
> Obtenir des clés API : [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> La recherche web est **optionnelle** — obtenez gratuitement l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois) ou utilisez le repli automatique intégré.
> Configurez votre clé API dans `~/.picoclaw/config.json`. Obtenez des clés API : [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). La recherche web est optionnelle — obtenez gratuitement l'[API Tavily](https://tavily.com) (1000 requêtes gratuites/mois) ou l'[API Brave Search](https://brave.com/search/api) (2000 requêtes gratuites/mois).
**1. Initialiser**
@ -222,8 +220,13 @@ picoclaw onboard
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1"
@ -231,7 +234,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model_name": "gpt4"
"model_name": "gpt-5.4"
}
},
"channels": {
@ -649,7 +652,6 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
├── IDENTITY.md # Identité de l'Agent
├── SOUL.md # Âme de l'Agent
├── TOOLS.md # Description des outils
└── USER.md # Préférences utilisateur
```
@ -978,8 +980,10 @@ Cette conception permet également le **support multi-agent** avec une sélectio
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obtenir Clé](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obtenir Clé](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obtenir Clé](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obtenir une clé](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -989,8 +993,13 @@ Cette conception permet également le **support multi-agent** avec une sélectio
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -1006,7 +1015,7 @@ Cette conception permet également le **support multi-agent** avec une sélectio
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -1017,8 +1026,17 @@ Cette conception permet également le **support multi-agent** avec une sélectio
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1061,14 +1079,14 @@ Configurez plusieurs points de terminaison pour le même nom de modèle—PicoCl
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1200,6 +1218,13 @@ Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assu
| Service | Offre Gratuite | Cas d'Utilisation |
| ---------------- | -------------------- | ------------------------------------- |
| **OpenRouter** | 200K tokens/mois | Multiples modèles (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/mois | Idéal pour les utilisateurs chinois |
| **Volcengine CodingPlan** | 9,9¥/premier mois | Idéal pour les utilisateurs chinois, multiples modèles SOTA (Doubao, DeepSeek, etc.) |
| **Zhipu** | 200K tokens/mois | Convient aux utilisateurs chinois |
| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Go で書かれた超効率 AI アシスタント</h1>
@ -168,9 +168,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 クイックスタート(ネイティブ)
> [!TIP]
> `~/.picoclaw/config.json` に API キーを設定してください。
> API キーの取得先: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Web 検索は **任意** です - 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)
> `~/.picoclaw/config.json` に API キーを設定してください。API キーの取得先: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。Web 検索は **任意** です — 無料の [Tavily API](https://tavily.com) (月 1000 クエリ無料) または [Brave Search API](https://brave.com/search/api) (月 2000 クエリ無料)。
**1. 初期化**
@ -184,8 +182,13 @@ picoclaw onboard
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1"
@ -193,7 +196,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model_name": "gpt4"
"model_name": "gpt-5.4"
}
},
"channels": {
@ -610,7 +613,6 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
├── HEARTBEAT.md # 定期タスクプロンプト30分ごとに確認
├── IDENTITY.md # エージェントのアイデンティティ
├── SOUL.md # エージェントのソウル
├── TOOLS.md # ツールの説明
└── USER.md # ユーザー設定
```
@ -919,8 +921,10 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [キーを取得](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | ローカル |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [キーを取得](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [キーを取得](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | カスタム | OAuthのみ |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -930,8 +934,13 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -947,7 +956,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -958,8 +967,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1002,14 +1020,14 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1120,9 +1138,16 @@ Web 検索を有効にするには:
| サービス | 無料枠 | ユースケース |
|---------|--------|------------|
| **OpenRouter** | 月 200K トークン | 複数モデルClaude, GPT-4 など) |
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
| **Volcengine CodingPlan** | 9.9元/初月 | 中国ユーザーに最適、複数のSOTAモデルDoubao、DeepSeek等 |
| **Zhipu** | 月 200K トークン | 中国ユーザーに適している |
| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
| **Tavily** | 月 1000 クエリ | AI エージェント検索最適化 |
| **Groq** | 無料枠あり | 高速推論Llama, Mixtral |
| **Cerebras** | 無料枠あり | 高速推論Llama, Qwen など) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Ultra-Efficient AI Assistant in Go</h1>
@ -194,6 +194,19 @@ docker compose -f docker/docker-compose.yml logs -f picoclaw-gateway
docker compose -f docker/docker-compose.yml --profile gateway down
```
### Launcher Mode (Web Console)
The `launcher` image includes all three binaries (`picoclaw`, `picoclaw-launcher`, `picoclaw-launcher-tui`) and starts the web console by default, which provides a browser-based UI for configuration and chat.
```bash
docker compose -f docker/docker-compose.yml --profile launcher up -d
```
Open http://localhost:18800 in your browser. The launcher manages the gateway process automatically.
> [!WARNING]
> The web console does not yet support authentication. Avoid exposing it to the public internet.
### Agent Mode (One-shot)
```bash
@ -214,9 +227,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Quick Start
> [!TIP]
> Set your API key in `~/.picoclaw/config.json`.
> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Web Search is **optional** - get free [Tavily API](https://tavily.com) (1000 free queries/month), [SearXNG](https://github.com/searxng/searxng) (free, self-hosted) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month) or use built-in auto fallback.
> Set your API Key in `~/.picoclaw/config.json`. Get API Keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Web search is optional — get a free [Tavily API](https://tavily.com) (1000 free queries/month) or [Brave Search API](https://brave.com/search/api) (2000 free queries/month).
**1. Initialize**
@ -231,7 +242,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt4",
"model_name": "gpt-5.4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@ -239,8 +250,13 @@ picoclaw onboard
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key",
"request_timeout": 300
},
@ -774,7 +790,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
├── IDENTITY.md # Agent identity
├── SOUL.md # Agent soul
├── TOOLS.md # Tool descriptions
└── USER.md # User preferences
```
@ -1018,9 +1033,11 @@ This design also enables **multi-agent support** with flexible provider selectio
| **LiteLLM Proxy** | `litellm/` | `http://localhost:4000/v1` | OpenAI | Your LiteLLM proxy key |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
| **Vivgrid** | `vivgrid/` | `https://api.vivgrid.com/v1` | OpenAI | [Get Key](https://vivgrid.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Get Key](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -1030,8 +1047,13 @@ This design also enables **multi-agent support** with flexible provider selectio
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -1047,7 +1069,7 @@ This design also enables **multi-agent support** with flexible provider selectio
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -1059,8 +1081,18 @@ This design also enables **multi-agent support** with flexible provider selectio
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1139,14 +1171,14 @@ Configure multiple endpoints for the same model name—PicoClaw will automatical
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1486,8 +1518,16 @@ This happens when another instance of the bot is running. Make sure only one `pi
| Service | Free Tier | Use Case |
| ---------------- | ------------------------ | ------------------------------------- |
| **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/month | Best for Chinese users |
| **Volcengine CodingPlan** | ¥9.9/first month | Best for Chinese users, multiple SOTA models (Doubao, DeepSeek, etc.) |
| **Zhipu** | 200K tokens/month | Suitable for Chinese users |
| **Brave Search** | Paid ($5/1000 queries) | Web search functionality |
| **SearXNG** | Unlimited (self-hosted) | Privacy-focused metasearch (70+ engines) |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
| **LongCat** | Up to 5M tokens/day | Fast inference (free tier) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Assistente de IA Ultra-Eficiente em Go</h1>
@ -207,9 +207,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Início Rápido
> [!TIP]
> Configure sua API key em `~/.picoclaw/config.json`.
> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas grátis/mês) ou use o fallback automático integrado.
> Configure sua API key em `~/.picoclaw/config.json`. Obtenha API keys: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Busca web é **opcional** — obtenha a [API Tavily](https://tavily.com) gratuita (1000 consultas grátis/mês) ou a [Brave Search API](https://brave.com/search/api) (2000 consultas grátis/mês).
**1. Inicializar**
@ -223,8 +221,13 @@ picoclaw onboard
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1"
@ -232,7 +235,7 @@ picoclaw onboard
],
"agents": {
"defaults": {
"model_name": "gpt4"
"model_name": "gpt-5.4"
}
},
"tools": {
@ -645,7 +648,6 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
├── IDENTITY.md # Identidade do Agente
├── SOUL.md # Alma do Agente
├── TOOLS.md # Descrição das ferramentas
└── USER.md # Preferencias do usuario
```
@ -974,8 +976,10 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Obter Chave](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Obter Chave](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Obter Chave](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Obter Chave](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -985,8 +989,13 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -1002,7 +1011,7 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -1013,8 +1022,17 @@ Este design também possibilita o **suporte multi-agent** com seleção flexíve
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1057,14 +1075,14 @@ Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-r
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1196,7 +1214,14 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
| Serviço | Plano Gratuito | Caso de Uso |
| --- | --- | --- |
| **OpenRouter** | 200K tokens/mês | Múltiplos modelos (Claude, GPT-4, etc.) |
| **Zhipu** | 200K tokens/mês | Melhor para usuários chineses |
| **Volcengine CodingPlan** | ¥9,9/primeiro mês | Ideal para usuários chineses, múltiplos modelos SOTA (Doubao, DeepSeek, etc.) |
| **Zhipu** | 200K tokens/mês | Adequado para usuários chineses |
| **Brave Search** | 2000 consultas/mês | Funcionalidade de busca web |
| **Groq** | Plano gratuito disponível | Inferência ultra-rápida (Llama, Mixtral) |
| **Cerebras** | Plano gratuito disponível | Inferência ultra-rápida (Llama 3.3 70B) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go</h1>
@ -187,9 +187,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 Bắt đầu nhanh
> [!TIP]
> Thiết lập API key trong `~/.picoclaw/config.json`.
> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn.
> Thiết lập API key trong `~/.picoclaw/config.json`. Lấy API key: [Volcengine (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM). Tìm kiếm web là **tùy chọn** — lấy [Tavily API](https://tavily.com) miễn phí (1000 truy vấn/tháng) hoặc [Brave Search API](https://brave.com/search/api) (2000 truy vấn/tháng).
**1. Khởi tạo**
@ -203,8 +201,13 @@ picoclaw onboard
{
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"request_timeout": 300,
"api_base": "https://api.openai.com/v1"
@ -617,7 +620,6 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định:
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
├── IDENTITY.md # Danh tính Agent
├── SOUL.md # Tâm hồn/Tính cách Agent
├── TOOLS.md # Mô tả công cụ
└── USER.md # Tùy chọn người dùng
```
@ -943,8 +945,10 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Lấy Khóa](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Lấy Khóa](https://cerebras.ai) |
| **Volcengine** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
| **VolcEngine (Doubao)** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [Lấy Khóa](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [Lấy Key](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -954,8 +958,13 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -971,7 +980,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -982,8 +991,17 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
**OpenAI**
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**VolcEngine (Doubao)**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -1026,14 +1044,14 @@ Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa ch
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -1165,6 +1183,13 @@ Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt
| Dịch vụ | Gói miễn phí | Trường hợp sử dụng |
| --- | --- | --- |
| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) |
| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc |
| **Volcengine CodingPlan** | ¥9.9/tháng đầu | Tốt nhất cho người dùng Trung Quốc, nhiều mô hình SOTA (Doubao, DeepSeek, v.v.) |
| **Zhipu** | 200K tokens/tháng | Phù hợp cho người dùng Trung Quốc |
| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web |
| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

View file

@ -1,5 +1,5 @@
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw" width="512">
<img src="assets/logo.webp" alt="PicoClaw" width="512">
<h1>PicoClaw: 基于Go语言的超高效 AI 助手</h1>
@ -208,9 +208,7 @@ docker compose -f docker/docker-compose.yml --profile gateway up -d
### 🚀 快速开始
> [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。获取 API Key: [火山引擎 (CodingPlan)](https://console.volcengine.com) (LLM) · [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)。网络搜索是 **可选的** — 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)。
**1. 初始化 (Initialize)**
@ -226,7 +224,7 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model_name": "gpt4",
"model_name": "gpt-5.4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
@ -234,8 +232,13 @@ picoclaw onboard
},
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "your-api-key",
"request_timeout": 300
},
@ -365,7 +368,6 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
├── IDENTITY.md # Agent 身份设定
├── SOUL.md # Agent 灵魂/性格
├── TOOLS.md # 工具描述
└── USER.md # 用户偏好
```
@ -515,8 +517,10 @@ Agent 读取 HEARTBEAT.md
| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [获取密钥](https://openrouter.ai/keys) |
| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | 本地 |
| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [获取密钥](https://cerebras.ai) |
| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
| **火山引擎Doubao** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
| **BytePlus** | `byteplus/` | `https://ark.ap-southeast.bytepluses.com/api/v3` | OpenAI | [获取密钥](https://console.volcengine.com) |
| **LongCat** | `longcat/` | `https://api.longcat.chat/openai` | OpenAI | [获取密钥](https://longcat.chat/platform) |
| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
@ -526,8 +530,13 @@ Agent 读取 HEARTBEAT.md
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-your-api-key"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key"
},
{
@ -543,7 +552,7 @@ Agent 读取 HEARTBEAT.md
],
"agents": {
"defaults": {
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -555,8 +564,18 @@ Agent 读取 HEARTBEAT.md
```json
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-..."
}
```
**火山引擎Doubao**
```json
{
"model_name": "ark-code-latest",
"model": "volcengine/ark-code-latest",
"api_key": "sk-..."
}
```
@ -622,14 +641,14 @@ Agent 读取 HEARTBEAT.md
{
"model_list": [
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api1.example.com/v1",
"api_key": "sk-key1"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_base": "https://api2.example.com/v1",
"api_key": "sk-key2"
}
@ -875,7 +894,15 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| 服务 | 免费层级 | 适用场景 |
| --- | --- | --- |
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
| **火山引擎 CodingPlan** | 9.9 元/首月 | 最适合国内用户,多种 SOTA 模型豆包、DeepSeek 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
| **LongCat** | 最多 5M tokens/天 | 推理速度快 (免费额度) |
---
<div align="center">
<img src="assets/logo.jpg" alt="PicoClaw Meme" width="512">
</div>

BIN
assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 386 KiB

After

Width:  |  Height:  |  Size: 345 KiB

View file

@ -1,6 +1,7 @@
package ui
import (
"fmt"
"os"
"os/exec"
"path/filepath"
@ -67,6 +68,7 @@ func Run() error {
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
@ -102,7 +104,7 @@ func (s *appState) pop() {
}
func (s *appState) mainMenu() tview.Primitive {
menu := NewMenu("Config Menu", nil)
menu := NewMenu("Menu", nil)
refreshMainMenu(menu, s)
menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
switch event.Key() {
@ -110,10 +112,7 @@ func (s *appState) mainMenu() tview.Primitive {
s.requestExit()
return nil
}
if event.Rune() == 'q' {
s.requestExit()
return nil
}
return event
})
@ -131,6 +130,32 @@ func (s *appState) refreshMenu(name string, menu *Menu) {
}
}
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)
@ -141,6 +166,7 @@ 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"
@ -153,7 +179,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
items := []MenuItem{
{
Label: rootModelLabel(selectedModel),
Description: rootModelDescription(selectedModel),
Description: rootModelDescription(),
Action: func() {
s.push("model", s.modelMenu())
},
@ -167,7 +193,7 @@ func refreshMainMenu(menu *Menu, s *appState) {
},
{
Label: rootChannelLabel(channelReady),
Description: rootChannelDescription(channelReady),
Description: fmt.Sprintf("%d/%d enabled", enabledCount, totalChannels),
Action: func() {
s.push("channel", s.channelMenu())
},
@ -311,16 +337,13 @@ func (s *appState) selectedModelName() string {
func rootModelLabel(selected string) string {
if selected == "" {
return "Model (no model selected)"
return "Model (None)"
}
return "Model (" + selected + ")"
}
func rootModelDescription(selected string) string {
if selected == "" {
return "no model selected"
}
return "selected"
func rootModelDescription() string {
return "Using SPACE to choose your model"
}
func rootChannelLabel(valid bool) string {
@ -330,13 +353,6 @@ func rootChannelLabel(valid bool) string {
return "Channel"
}
func rootChannelDescription(valid bool) string {
if !valid {
return "no channel enabled"
}
return "enabled"
}
func (s *appState) startTalk() {
if !s.isActiveModelValid() {
s.showMessage("Model required", "Select a valid model before starting talk")

View file

@ -12,7 +12,6 @@ import (
func (s *appState) buildChannelMenuItems() []MenuItem {
return []MenuItem{
{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
channelItem(
"Telegram",
"Telegram bot settings",
@ -101,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive {
s.pop()
return nil
}
if event.Rune() == 'q' {
s.pop()
return nil
}
return event
})
return menu

View file

@ -14,23 +14,7 @@ import (
)
func (s *appState) modelMenu() tview.Primitive {
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
items = append(items,
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
MenuItem{
Label: "Add model",
Description: "Append a new model entry",
Action: func() {
s.addModel(
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
s.modelForm(len(s.config.ModelList)-1),
)
},
},
)
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
@ -57,6 +41,23 @@ func (s *appState) modelMenu() tview.Primitive {
},
})
}
// 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.4"},
)
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 {
@ -64,14 +65,11 @@ func (s *appState) modelMenu() tview.Primitive {
s.pop()
return nil
}
if event.Rune() == 'q' {
s.pop()
return nil
}
if event.Rune() == ' ' {
row, _ := menu.GetSelection()
if row > 0 && row <= len(s.config.ModelList) {
model := s.config.ModelList[row-1]
if row >= 0 && row < len(s.config.ModelList) {
model := s.config.ModelList[row]
if !isModelValid(model) {
s.showMessage(
"Invalid model",
@ -95,12 +93,23 @@ 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))
form.SetButtonBackgroundColor(tcell.NewRGBColor(80, 250, 123))
form.SetButtonTextColor(tcell.NewRGBColor(12, 13, 22))
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)
@ -158,7 +167,21 @@ func (s *appState) modelForm(index int) tview.Primitive {
})
form.AddButton("Delete", func() {
s.deleteModel(index)
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)
@ -215,7 +238,7 @@ func modelStatusColor(valid bool, selected bool) *tcell.Color {
func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.ModelConfig) {
for i, model := range models {
row := i + 1
row := i
label := fmt.Sprintf("%s (%s)", model.ModelName, model.Model)
isValid := isModelValid(model)
if model.ModelName == currentModel && currentModel != "" {
@ -234,23 +257,7 @@ func refreshModelMenu(menu *Menu, currentModel string, models []picoclawconfig.M
}
func refreshModelMenuFromState(menu *Menu, s *appState) {
items := make([]MenuItem, 0, 2+len(s.config.ModelList))
items = append(items,
MenuItem{Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
MenuItem{
Label: "Add model",
Description: "Append a new model entry",
Action: func() {
s.addModel(
picoclawconfig.ModelConfig{ModelName: "new-model", Model: "openai/gpt-5.2"},
)
s.push(
fmt.Sprintf("model-%d", len(s.config.ModelList)-1),
s.modelForm(len(s.config.ModelList)-1),
)
},
},
)
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
@ -277,6 +284,19 @@ func refreshModelMenuFromState(menu *Menu, s *appState) {
},
})
}
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.4"},
)
s.push(fmt.Sprintf("model-%d", len(s.config.ModelList)-1), s.modelForm(len(s.config.ModelList)-1))
},
},
)
menu.applyItems(items)
}
@ -287,6 +307,38 @@ func isModelValid(model picoclawconfig.ModelConfig) bool {
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

View file

@ -41,3 +41,15 @@ func bannerView() *tview.TextView {
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

@ -6,11 +6,10 @@ import (
func NewAgentCommand() *cobra.Command {
var (
message string
sessionKey string
model string
debug bool
orchestrationEnabled bool
message string
sessionKey string
model string
debug bool
)
cmd := &cobra.Command{
@ -18,7 +17,7 @@ func NewAgentCommand() *cobra.Command {
Short: "Interact with the agent directly",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return agentCmd(message, sessionKey, model, debug, orchestrationEnabled)
return agentCmd(message, sessionKey, model, debug)
},
}
@ -26,7 +25,6 @@ func NewAgentCommand() *cobra.Command {
cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
cmd.Flags().BoolVar(&orchestrationEnabled, "orchestration", false, "Enable orchestration mode")
return cmd
}

View file

@ -18,7 +18,7 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled bool) error {
func agentCmd(message, sessionKey, model string, debug bool) error {
if sessionKey == "" {
sessionKey = "cli:default"
}
@ -37,10 +37,6 @@ func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled boo
cfg.Agents.Defaults.ModelName = model
}
if orchestrationEnabled {
cfg.Agents.Defaults.Orchestration = true
}
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
return fmt.Errorf("error creating provider: %w", err)
@ -54,6 +50,7 @@ func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled boo
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

@ -72,14 +72,14 @@ 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",
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 +90,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
}
@ -318,13 +318,13 @@ func authLoginPasteToken(provider string) error {
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
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)

View file

@ -1,29 +1,46 @@
package gateway
import (
"fmt"
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/utils"
)
func NewGatewayCommand() *cobra.Command {
var (
debug bool
orchestration bool
enableStats bool
)
var debug bool
var noTruncate bool
var orchestration bool
var enableStats 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, orchestration, enableStats)
},
}
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().BoolVar(&orchestration, "orchestration", false, "Enable subagent orchestration")
cmd.Flags().BoolVar(&enableStats, "stats", false, "Enable stats tracking")
cmd.Flags().BoolVar(&enableStats, "stats", false, "Enable stats collection")
return cmd
}

View file

@ -1,23 +1,14 @@
package internal
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/sipeed/picoclaw/pkg/config"
)
const Logo = "🦞"
var (
version = "dev"
gitCommit string
buildTime string
goVersion string
)
// GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string {
@ -40,25 +31,19 @@ func LoadConfig() (*config.Config, error) {
}
// 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

@ -0,0 +1,83 @@
package internal
import (
"runtime"
"testing"
"github.com/stretchr/testify/assert"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestFormatVersion_NoGitCommit(t *testing.T) {
oldVersion, oldGit := config.Version, config.GitCommit
t.Cleanup(func() { config.Version, config.GitCommit = oldVersion, oldGit })
config.Version = "1.2.3"
config.GitCommit = ""
assert.Equal(t, "1.2.3", FormatVersion())
}
func TestFormatVersion_WithGitCommit(t *testing.T) {
oldVersion, oldGit := config.Version, config.GitCommit
t.Cleanup(func() { config.Version, config.GitCommit = oldVersion, oldGit })
config.Version = "1.2.3"
config.GitCommit = "abc123"
assert.Equal(t, "1.2.3 (git: abc123)", FormatVersion())
}
func TestFormatBuildInfo_UsesBuildTimeAndGoVersion_WhenSet(t *testing.T) {
oldBuildTime, oldGoVersion := config.BuildTime, config.GoVersion
t.Cleanup(func() { config.BuildTime, config.GoVersion = oldBuildTime, oldGoVersion })
config.BuildTime = "2026-02-20T00:00:00Z"
config.GoVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Equal(t, config.BuildTime, build)
assert.Equal(t, config.GoVersion, goVer)
}
func TestFormatBuildInfo_EmptyBuildTime_ReturnsEmptyBuild(t *testing.T) {
oldBuildTime, oldGoVersion := config.BuildTime, config.GoVersion
t.Cleanup(func() { config.BuildTime, config.GoVersion = oldBuildTime, oldGoVersion })
config.BuildTime = ""
config.GoVersion = "go1.23.0"
build, goVer := FormatBuildInfo()
assert.Empty(t, build)
assert.Equal(t, config.GoVersion, goVer)
}
func TestFormatBuildInfo_EmptyGoVersion_FallsBackToRuntimeVersion(t *testing.T) {
oldBuildTime, oldGoVersion := config.BuildTime, config.GoVersion
t.Cleanup(func() { config.BuildTime, config.GoVersion = oldBuildTime, oldGoVersion })
config.BuildTime = "x"
config.GoVersion = ""
build, goVer := FormatBuildInfo()
assert.Equal(t, "x", build)
assert.Equal(t, runtime.Version(), goVer)
}
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")
got := GetConfigPath()
want := "/tmp/custom/config.json"
assert.Equal(t, want, got)
}

View file

@ -40,65 +40,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 +53,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

@ -1,6 +1,14 @@
package onboard
import "github.com/spf13/cobra"
import (
"embed"
"github.com/spf13/cobra"
)
//go:generate cp -r ../../../../workspace .
//go:embed workspace
var embeddedFiles embed.FS
func NewOnboardCommand() *cobra.Command {
cmd := &cobra.Command{

View file

@ -2,6 +2,7 @@ package onboard
import (
"fmt"
"io/fs"
"os"
"path/filepath"
@ -9,30 +10,6 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
)
var workspaceTemplates = map[string]string{
"AGENTS.md": `# Agent Instructions
You are a helpful AI assistant. Be concise, accurate, and friendly.
`,
"IDENTITY.md": `# Identity
## Name
PicoClaw 🦞
`,
"SOUL.md": `# Soul
I am picoclaw, a lightweight AI assistant powered by AI.
`,
"USER.md": `# User
Information about user goes here.
`,
"memory/MEMORY.md": `# Long-term Memory
This file stores important information that should persist across sessions.
`,
}
func onboard() {
configPath := internal.GetConfigPath()
@ -77,19 +54,48 @@ func createWorkspaceTemplates(workspace string) {
}
func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("failed to create target directory: %w", err)
return fmt.Errorf("Failed to create target directory: %w", err)
}
for relPath, content := range workspaceTemplates {
targetPath := filepath.Join(targetDir, relPath)
// Walk through all files in embed.FS
err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip directories
if d.IsDir() {
return nil
}
// Read embedded file
data, err := embeddedFiles.ReadFile(path)
if err != nil {
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
}
new_path, err := filepath.Rel("workspace", path)
if err != nil {
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
}
// Build target file path
targetPath := filepath.Join(targetDir, new_path)
// Ensure target file's directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", filepath.Dir(targetPath), err)
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
}
if err := os.WriteFile(targetPath, []byte(content), 0o644); err != nil {
return fmt.Errorf("failed to write file %s: %w", targetPath, err)
}
}
return nil
// Write file
if err := os.WriteFile(targetPath, data, 0o644); err != nil {
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
}
return nil
})
return err
}

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)
}

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

@ -22,15 +22,16 @@ import (
"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"
)
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 v%s\n\n", internal.Logo, config.GetVersion())
cmd := &cobra.Command{
Use: "picoclaw",
Short: short,
Example: "picoclaw list",
Example: "picoclaw version",
}
cmd.AddCommand(

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 v%s\n\n", internal.Logo, config.GetVersion())
assert.Equal(t, "picoclaw", cmd.Use)
assert.Equal(t, short, cmd.Short)

View file

@ -3,7 +3,7 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model_name": "gpt4",
"model_name": "gpt-5.4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20,
@ -13,8 +13,8 @@
},
"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"
},
@ -36,14 +36,19 @@
"api_key": "sk-your-deepseek-key"
},
{
"model_name": "loadbalanced-gpt4",
"model": "openai/gpt-5.2",
"model_name": "longcat",
"model": "longcat/LongCat-Flash-Thinking",
"api_key": "your-longcat-api-key"
},
{
"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"
}
@ -194,8 +199,13 @@
"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
@ -269,6 +279,10 @@
"avian": {
"api_key": "",
"api_base": "https://api.avian.io/v1"
},
"longcat": {
"api_key": "",
"api_base": "https://api.longcat.chat/openai"
}
},
"tools": {
@ -279,6 +293,9 @@
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"api_keys": [
"YOUR_BRAVE_API_KEY"
],
"max_results": 5
},
"tavily": {
@ -293,7 +310,10 @@
},
"perplexity": {
"enabled": false,
"api_key": "",
"api_key": "pplx-xxx",
"api_keys": [
"pplx-xxx"
],
"max_results": 5
},
"searxng": {
@ -316,6 +336,13 @@
},
"mcp": {
"enabled": false,
"discovery": {
"enabled": false,
"ttl": 5,
"max_search_results": 5,
"use_bm25": true,
"use_regex": false
},
"servers": {
"context7": {
"enabled": false,
@ -459,6 +486,9 @@
"enabled": false,
"monitor_usb": true
},
"voice": {
"echo_transcription": false
},
"gateway": {
"host": "127.0.0.1",
"port": 18790

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"]

View file

@ -19,7 +19,7 @@ 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
@ -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: on-failure
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

@ -22,7 +22,8 @@ Add this to `config.json`:
"enabled": true,
"text": "Thinking..."
},
"reasoning_channel_id": ""
"reasoning_channel_id": "",
"message_format": "richtext"
}
}
}
@ -42,10 +43,12 @@ Add this to `config.json`:
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
| placeholder | object | No | Placeholder message config |
| reasoning_channel_id | string | No | Target channel for reasoning output |
| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only |
## 3. Currently Supported
- Text message send/receive
- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)
- Configurable message format (`richtext` / `plain`)
- Incoming image/audio/video/file download (MediaStore first, local path fallback)
- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
- Outgoing image/audio/video/file upload and send

33
docs/debug.md Normal file
View file

@ -0,0 +1,33 @@
# Debugging PicoClaw
PicoClaw performs multiple complex interactions under the hood for every single request it receives—from routing messages and evaluating complexity, to executing tools and adapting to model failures. Being able to see exactly what is happening is crucial, not just for troubleshooting potential issues, but also for truly understanding how the agent operates.
## Starting PicoClaw in Debug Mode
To get detailed information about what the agent is doing (LLM requests, tool calls, message routing), you can start the PicoClaw gateway with the debug flag:
```bash
picoclaw gateway --debug
# or
picoclaw gateway -d
```
In this mode, the system will format the logs extensively and display previews of system prompts and tool execution results.
## Disabling Log Truncation (Full Logs)
By default, PicoClaw truncates very long strings (such as the *System Prompt* or large JSON output results) in the debug logs to keep the console readable.
If you need to inspect the complete output of a command or the exact payload sent to the LLM model, you can use the `--no-truncate` flag.
**Note:** This flag *only* works when combined with the `--debug` mode.
```bash
picoclaw gateway --debug --no-truncate
```
When this flag is active, the global truncation function is disabled. This is extremely useful for:
* Verifying the exact syntax of the messages sent to the provider.
* Reading the complete output of tools like `exec`, `web_fetch`, or `read_file`.
* Debugging the session history saved in memory.

View file

@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity.
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
1. **Model-centric**: Users care about models, not providers
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6`
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
### 2.2 New Configuration Structure
@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
"api_key": "sk-xxx"
},
{
"model_name": "gpt-5.2",
"model": "openai/gpt-5.2",
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",
"api_key": "sk-xxx"
},
{
@ -128,7 +128,7 @@ type Config struct {
type ModelConfig struct {
// Required
ModelName string `json:"model_name"` // user-facing name (alias)
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4
// Common config
APIBase string `json:"api_base,omitempty"`
@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field:
"model": "deepseek-chat"
},
"coder": {
"model": "gpt-5.2",
"model": "gpt-5.4",
"system_prompt": "You are a coding assistant..."
},
"translator": {
@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_
model_list:
- model_name: gpt-4o
litellm_params:
model: openai/gpt-5.2
model: openai/gpt-5.4
api_key: xxx
- model_name: my-custom
litellm_params:

View file

@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages:
"agents": {
"defaults": {
"provider": "openai",
"model": "gpt-5.2"
"model": "gpt-5.4"
}
}
}
@ -53,7 +53,7 @@ The new `model_list` configuration offers several advantages:
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
},
@ -82,7 +82,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| Prefix | Description | Example |
|--------|-------------|---------|
| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
@ -109,7 +109,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| Field | Required | Description |
|-------|----------|-------------|
| `model_name` | Yes | User-facing alias for the model |
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
| `api_base` | No | API endpoint URL |
| `api_key` | No* | API authentication key |
| `proxy` | No | HTTP proxy URL |
@ -130,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load:
"model_list": [
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model": "openai/gpt-5.4",
"api_key": "sk-key1",
"api_base": "https://api1.example.com/v1"
},
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model": "openai/gpt-5.4",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
},
{
"model_name": "gpt4",
"model": "openai/gpt-5.2",
"model": "openai/gpt-5.4",
"api_key": "sk-key3",
"api_base": "https://api3.example.com/v1"
}

View file

@ -7,11 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
```json
{
"tools": {
"web": { ... },
"mcp": { ... },
"exec": { ... },
"cron": { ... },
"skills": { ... }
"web": {
...
},
"mcp": {
...
},
"exec": {
...
},
"cron": {
...
},
"skills": {
...
}
}
}
```
@ -23,7 +33,7 @@ Web tools are used for web search and fetching.
### Brave
| Config | Type | Default | Description |
| ------------- | ------ | ------- | ------------------------- |
|---------------|--------|---------|---------------------------|
| `enabled` | bool | false | Enable Brave search |
| `api_key` | string | - | Brave Search API key |
| `max_results` | int | 5 | Maximum number of results |
@ -31,14 +41,14 @@ Web tools are used for web search and fetching.
### DuckDuckGo
| Config | Type | Default | Description |
| ------------- | ---- | ------- | ------------------------- |
|---------------|------|---------|---------------------------|
| `enabled` | bool | true | Enable DuckDuckGo search |
| `max_results` | int | 5 | Maximum number of results |
### Perplexity
| Config | Type | Default | Description |
| ------------- | ------ | ------- | ------------------------- |
|---------------|--------|---------|---------------------------|
| `enabled` | bool | false | Enable Perplexity search |
| `api_key` | string | - | Perplexity API key |
| `max_results` | int | 5 | Maximum number of results |
@ -48,7 +58,7 @@ Web tools are used for web search and fetching.
The exec tool is used to execute shell commands.
| Config | Type | Default | Description |
| ---------------------- | ----- | ------- | ------------------------------------------ |
|------------------------|-------|---------|--------------------------------------------|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands:
"tools": {
"exec": {
"enable_deny_patterns": true,
"custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"]
"custom_deny_patterns": [
"\\brm\\s+-r\\b",
"\\bkillall\\s+python"
]
}
}
}
@ -92,24 +105,47 @@ By default, PicoClaw blocks the following dangerous commands:
The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description |
| ---------------------- | ---- | ------- | ---------------------------------------------- |
|------------------------|------|---------|------------------------------------------------|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
## MCP Tool
The MCP tool enables integration with external Model Context Protocol servers.
### Tool Discovery (Lazy Loading)
When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default.
Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex).
When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked"
and injected into the context for a configured number of turns (`ttl`).
### Global Config
| Config | Type | Default | Description |
| --------- | ------ | ------- | ----------------------------------- |
| `enabled` | bool | false | Enable MCP integration globally |
| `servers` | object | `{}` | Map of server name to server config |
| Config | Type | Default | Description |
|-------------|--------|---------|----------------------------------------------|
| `enabled` | bool | false | Enable MCP integration globally |
| `discovery` | object | `{}` | Configuration for Tool Discovery (see below) |
| `servers` | object | `{}` | Map of server name to server config |
### Discovery Config (`discovery`)
| Config | Type | Default | Description |
|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------|
| `enabled` | bool | false | If true, MCP tools are hidden and loaded on-demand via search. If false, all tools are loaded |
| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked |
| `max_search_results` | int | 5 | Maximum number of tools returned per search query |
| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search |
| `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) |
> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`),
> otherwise the application will fail to start.
### Per-Server Config
| Config | Type | Required | Description |
| ---------- | ------ | -------- | ------------------------------------------ |
|------------|--------|----------|--------------------------------------------|
| `enabled` | bool | yes | Enable this MCP server |
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
| `command` | string | stdio | Executable command for stdio transport |
@ -122,8 +158,8 @@ The MCP tool enables integration with external Model Context Protocol servers.
### Transport Behavior
- If `type` is omitted, transport is auto-detected:
- `url` is set → `sse`
- `command` is set → `stdio`
- `url` is set → `sse`
- `command` is set → `stdio`
- `http` and `sse` both use `url` + optional `headers`.
- `env` and `env_file` are only applied to `stdio` servers.
@ -140,7 +176,11 @@ The MCP tool enables integration with external Model Context Protocol servers.
"filesystem": {
"enabled": true,
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp"
]
}
}
}
@ -170,20 +210,76 @@ The MCP tool enables integration with external Model Context Protocol servers.
}
```
#### 3) Massive MCP setup with Tool Discovery enabled
*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools
dynamically only when requested by the user.*
```json
{
"tools": {
"mcp": {
"enabled": true,
"discovery": {
"enabled": true,
"ttl": 5,
"max_search_results": 5,
"use_bm25": true,
"use_regex": false
},
"servers": {
"github": {
"enabled": true,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-github"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
}
},
"postgres": {
"enabled": true,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-postgres",
"postgresql://user:password@localhost/dbname"
]
},
"slack": {
"enabled": true,
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-slack"
],
"env": {
"SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
"SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
}
}
}
}
}
}
```
## Skills Tool
The skills tool configures skill discovery and installation via registries like ClawHub.
### Registries
| Config | Type | Default | Description |
| ---------------------------------- | ------ | -------------------- | ----------------------- |
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
| Config | Type | Default | Description |
|------------------------------------|--------|----------------------|----------------------------------------------|
| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
### Configuration Example
@ -217,4 +313,5 @@ For example:
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than environment variables.
Note: Nested map-style config (for example `tools.mcp.servers.<name>.*`) is configured in `config.json` rather than
environment variables.

3
go.mod
View file

@ -10,6 +10,7 @@ require (
github.com/chzyer/readline v1.5.1
github.com/ergochat/irc-go v0.5.0
github.com/gdamore/tcell/v2 v2.13.8
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/h2non/filetype v1.1.3
@ -28,6 +29,7 @@ require (
golang.org/x/oauth2 v0.35.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.46.1
)
@ -59,7 +61,6 @@ require (
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect

4
go.sum
View file

@ -79,6 +79,8 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@ -269,8 +271,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=

File diff suppressed because it is too large Load diff

View file

@ -12,103 +12,70 @@ import (
)
// setupWorkspace creates a temporary workspace with standard directories and optional files.
// Returns the tmpDir path; caller should defer os.RemoveAll(tmpDir).
func setupWorkspace(t *testing.T, files map[string]string) string {
t.Helper()
tmpDir, err := os.MkdirTemp("", "picoclaw-test-*")
if err != nil {
t.Fatal(err)
}
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
for name, content := range files {
dir := filepath.Dir(filepath.Join(tmpDir, name))
os.MkdirAll(dir, 0o755)
if err := os.WriteFile(filepath.Join(tmpDir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
return tmpDir
}
// TestSingleSystemMessage verifies that BuildMessages always produces exactly one
// system message regardless of summary/history variations.
// Fix: multiple system messages break Anthropic (top-level system param) and
// Codex (only reads last system message as instructions).
func TestSingleSystemMessage(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{
"IDENTITY.md": "# Identity\nTest agent.",
})
defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir)
tests := []struct {
name string
name string
history []providers.Message
summary string
message string
}{
{
name: "no summary, no history",
name: "no summary, no history",
summary: "",
message: "hello",
},
{
name: "with summary",
name: "with summary",
summary: "Previous conversation discussed X",
message: "hello",
},
{
name: "with history and summary",
history: []providers.Message{
{Role: "user", Content: "hi"},
{Role: "assistant", Content: "hello"},
},
summary: strings.Repeat("Long summary text. ", 50),
message: "new message",
},
{
name: "system message in history is filtered",
history: []providers.Message{
{Role: "system", Content: "stale system prompt from previous session"},
{Role: "user", Content: "hi"},
{Role: "assistant", Content: "hello"},
},
summary: "",
message: "new message",
},
}
@ -118,44 +85,35 @@ func TestSingleSystemMessage(t *testing.T) {
msgs := cb.BuildMessages(tt.history, tt.summary, tt.message, nil, "test", "chat1")
systemCount := 0
for _, m := range msgs {
if m.Role == "system" {
systemCount++
}
}
if systemCount != 1 {
t.Errorf("expected exactly 1 system message, got %d", systemCount)
}
if msgs[0].Role != "system" {
t.Errorf("first message should be system, got %s", msgs[0].Role)
}
if msgs[len(msgs)-1].Role != "user" {
t.Errorf("last message should be user, got %s", msgs[len(msgs)-1].Role)
}
// System message must contain identity (static) and time (dynamic)
sys := msgs[0].Content
if !strings.Contains(sys, "picoclaw") {
t.Error("system message missing identity")
}
if !strings.Contains(sys, "Current Time") {
t.Error("system message missing dynamic time context")
}
// Summary handling
if tt.summary != "" {
if !strings.Contains(sys, "CONTEXT_SUMMARY:") {
t.Error("summary present but CONTEXT_SUMMARY prefix missing")
}
if !strings.Contains(sys, tt.summary[:20]) {
t.Error("summary content not found in system message")
}
@ -169,46 +127,29 @@ func TestSingleSystemMessage(t *testing.T) {
}
// TestMtimeAutoInvalidation verifies that the cache detects source file changes
// via mtime without requiring explicit InvalidateCache().
// Fix: original implementation had no auto-invalidation — edits to bootstrap files,
// memory, or skills were invisible until process restart.
func TestMtimeAutoInvalidation(t *testing.T) {
tests := []struct {
name string
file string // relative path inside workspace
contentV1 string
contentV2 string
name string
file string // relative path inside workspace
contentV1 string
contentV2 string
checkField string // substring to verify in rebuilt prompt
}{
{
name: "bootstrap file change",
file: "IDENTITY.md",
contentV1: "# Original Identity",
contentV2: "# Updated Identity",
name: "bootstrap file change",
file: "IDENTITY.md",
contentV1: "# Original Identity",
contentV2: "# Updated Identity",
checkField: "Updated Identity",
},
{
name: "memory file change",
file: "memory/MEMORY.md",
contentV1: "# Memory\nUser likes Go.",
contentV2: "# Memory\nUser likes Rust.",
name: "memory file change",
file: "memory/MEMORY.md",
contentV1: "# Memory\nUser likes Go.",
contentV2: "# Memory\nUser likes Rust.",
checkField: "User likes Rust",
},
}
@ -216,7 +157,6 @@ func TestMtimeAutoInvalidation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{tt.file: tt.contentV1})
defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir)
@ -224,39 +164,26 @@ func TestMtimeAutoInvalidation(t *testing.T) {
sp1 := cb.BuildSystemPromptWithCache()
// Overwrite file and set future mtime to ensure detection.
// Use 2s offset for filesystem mtime resolution safety (some FS
// have 1s or coarser granularity, especially in CI containers).
fullPath := filepath.Join(tmpDir, tt.file)
os.WriteFile(fullPath, []byte(tt.contentV2), 0o644)
future := time.Now().Add(2 * time.Second)
os.Chtimes(fullPath, future, future)
// Verify sourceFilesChangedLocked detects the mtime change
cb.systemPromptMutex.RLock()
changed := cb.sourceFilesChangedLocked()
cb.systemPromptMutex.RUnlock()
if !changed {
t.Fatalf("sourceFilesChangedLocked() should detect %s change", tt.file)
}
// Should auto-rebuild without explicit InvalidateCache()
sp2 := cb.BuildSystemPromptWithCache()
if sp1 == sp2 {
t.Errorf("cache not rebuilt after %s change", tt.file)
}
if !strings.Contains(sp2, tt.checkField) {
t.Errorf("rebuilt prompt missing expected content %q", tt.checkField)
}
@ -264,34 +191,23 @@ func TestMtimeAutoInvalidation(t *testing.T) {
}
// Skills directory mtime change
t.Run("skills dir change", func(t *testing.T) {
tmpDir := setupWorkspace(t, nil)
defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir)
_ = cb.BuildSystemPromptWithCache() // populate cache
// Touch skills directory (simulate new skill installed)
skillsDir := filepath.Join(tmpDir, "skills")
future := time.Now().Add(2 * time.Second)
os.Chtimes(skillsDir, future, future)
// Verify sourceFilesChangedLocked detects it (cache is rebuilt)
// We confirm by checking internal state: a second call should rebuild.
cb.systemPromptMutex.RLock()
changed := cb.sourceFilesChangedLocked()
cb.systemPromptMutex.RUnlock()
if !changed {
t.Error("sourceFilesChangedLocked() should detect skills dir mtime change")
}
@ -299,22 +215,17 @@ func TestMtimeAutoInvalidation(t *testing.T) {
}
// TestExplicitInvalidateCache verifies that InvalidateCache() forces a rebuild
// even when source files haven't changed (useful for tests and reload commands).
func TestExplicitInvalidateCache(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{
"IDENTITY.md": "# Test Identity",
})
defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir)
sp1 := cb.BuildSystemPromptWithCache()
cb.InvalidateCache()
sp2 := cb.BuildSystemPromptWithCache()
if sp1 != sp2 {
@ -322,39 +233,29 @@ func TestExplicitInvalidateCache(t *testing.T) {
}
// Verify cachedAt was reset
cb.InvalidateCache()
cb.systemPromptMutex.RLock()
if !cb.cachedAt.IsZero() {
t.Error("cachedAt should be zero after InvalidateCache()")
}
cb.systemPromptMutex.RUnlock()
}
// TestCacheStability verifies that the static prompt is stable across repeated calls
// when no files change (regression test for issue #607).
func TestCacheStability(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{
"IDENTITY.md": "# Identity\nContent",
"SOUL.md": "# Soul\nContent",
"SOUL.md": "# Soul\nContent",
})
defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir)
results := make([]string, 5)
for i := range results {
results[i] = cb.BuildSystemPromptWithCache()
}
for i := 1; i < len(results); i++ {
if results[i] != results[0] {
t.Errorf("cached prompt changed between call 0 and %d", i)
@ -362,47 +263,32 @@ func TestCacheStability(t *testing.T) {
}
// Static prompt must NOT contain per-request data
if strings.Contains(results[0], "Current Time") {
t.Error("static cached prompt should not contain time (added dynamically)")
}
}
// TestNewFileCreationInvalidatesCache verifies that creating a source file that
// did not exist when the cache was built triggers a cache rebuild.
// This catches the "from nothing to something" edge case that the old
// modifiedSince (return false on stat error) would miss.
func TestNewFileCreationInvalidatesCache(t *testing.T) {
tests := []struct {
name string
file string // relative path inside workspace
content string
name string
file string // relative path inside workspace
content string
checkField string // substring to verify in rebuilt prompt
}{
{
name: "new bootstrap file",
file: "SOUL.md",
content: "# Soul\nBe kind and helpful.",
name: "new bootstrap file",
file: "SOUL.md",
content: "# Soul\nBe kind and helpful.",
checkField: "Be kind and helpful",
},
{
name: "new memory file",
file: "memory/MEMORY.md",
content: "# Memory\nUser prefers dark mode.",
name: "new memory file",
file: "memory/MEMORY.md",
content: "# Memory\nUser prefers dark mode.",
checkField: "User prefers dark mode",
},
}
@ -410,41 +296,29 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Start with an empty workspace (no bootstrap/memory files)
tmpDir := setupWorkspace(t, nil)
defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir)
// Populate cache — file does not exist yet
sp1 := cb.BuildSystemPromptWithCache()
if strings.Contains(sp1, tt.checkField) {
t.Fatalf("prompt should not contain %q before file is created", tt.checkField)
}
// Create the file after cache was built
fullPath := filepath.Join(tmpDir, tt.file)
os.MkdirAll(filepath.Dir(fullPath), 0o755)
if err := os.WriteFile(fullPath, []byte(tt.content), 0o644); err != nil {
t.Fatal(err)
}
// Set future mtime to guarantee detection
future := time.Now().Add(2 * time.Second)
os.Chtimes(fullPath, future, future)
// Cache should auto-invalidate because file went from absent -> present
sp2 := cb.BuildSystemPromptWithCache()
if !strings.Contains(sp2, tt.checkField) {
t.Errorf("cache not invalidated on new file creation: expected %q in prompt", tt.checkField)
}
@ -453,89 +327,58 @@ func TestNewFileCreationInvalidatesCache(t *testing.T) {
}
// TestSkillFileContentChange verifies that modifying a skill file's content
// (not just the directory structure) invalidates the cache.
// This is the scenario where directory mtime alone is insufficient — on most
// filesystems, editing a file inside a directory does NOT update the parent
// directory's mtime.
func TestSkillFileContentChange(t *testing.T) {
skillMD := `---
name: test-skill
description: "A test skill"
---
# Test Skill v1
Original content.`
tmpDir := setupWorkspace(t, map[string]string{
"skills/test-skill/SKILL.md": skillMD,
})
defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir)
// Populate cache
sp1 := cb.BuildSystemPromptWithCache()
_ = sp1 // cache is warm
// Modify the skill file content (without touching the skills/ directory)
updatedSkillMD := `---
name: test-skill
description: "An updated test skill"
---
# Test Skill v2
Updated content.`
skillPath := filepath.Join(tmpDir, "skills", "test-skill", "SKILL.md")
if err := os.WriteFile(skillPath, []byte(updatedSkillMD), 0o644); err != nil {
t.Fatal(err)
}
// Set future mtime on the skill file only (NOT the directory)
future := time.Now().Add(2 * time.Second)
os.Chtimes(skillPath, future, future)
// Verify that sourceFilesChangedLocked detects the content change
cb.systemPromptMutex.RLock()
changed := cb.sourceFilesChangedLocked()
cb.systemPromptMutex.RUnlock()
if !changed {
t.Error("sourceFilesChangedLocked() should detect skill file content change")
}
// Verify cache is actually rebuilt with new content
sp2 := cb.BuildSystemPromptWithCache()
if sp1 == sp2 && strings.Contains(sp1, "test-skill") {
// If the skill appeared in the prompt and the prompt didn't change,
// the cache was not invalidated.
t.Error("cache should be invalidated when skill file content changes")
}
}
@ -697,75 +540,53 @@ description: delete-me-v1
}
// TestConcurrentBuildSystemPromptWithCache verifies that multiple goroutines
// can safely call BuildSystemPromptWithCache concurrently without producing
// empty results, panics, or data races.
// Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache
func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
tmpDir := setupWorkspace(t, map[string]string{
"IDENTITY.md": "# Identity\nConcurrency test agent.",
"SOUL.md": "# Soul\nBe helpful.",
"memory/MEMORY.md": "# Memory\nUser prefers Go.",
"IDENTITY.md": "# Identity\nConcurrency test agent.",
"SOUL.md": "# Soul\nBe helpful.",
"memory/MEMORY.md": "# Memory\nUser prefers Go.",
"skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo",
})
defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir)
const goroutines = 20
const iterations = 50
var wg sync.WaitGroup
errs := make(chan string, goroutines*iterations)
for g := range goroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := range iterations {
result := cb.BuildSystemPromptWithCache()
if result == "" {
errs <- "empty prompt returned"
return
}
if !strings.Contains(result, "picoclaw") {
errs <- "prompt missing identity"
return
}
// Also exercise BuildMessages concurrently
msgs := cb.BuildMessages(nil, "", "hello", nil, "test", "chat")
if len(msgs) < 2 {
errs <- "BuildMessages returned fewer than 2 messages"
return
}
if msgs[0].Role != "system" {
errs <- "first message not system"
return
}
// Occasionally invalidate to exercise the write path
if i%10 == 0 {
cb.InvalidateCache()
}
@ -774,7 +595,6 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
}
wg.Wait()
close(errs)
for errMsg := range errs {
@ -785,90 +605,64 @@ func TestConcurrentBuildSystemPromptWithCache(t *testing.T) {
// BenchmarkBuildMessagesWithCache measures caching performance.
// TestEmptyWorkspaceBaselineDetectsNewFiles verifies that when the cache is
// built on an empty workspace (no tracked files exist), creating a file
// afterwards still triggers cache invalidation. This validates the
// time.Unix(1, 0) fallback for maxMtime: any real file's mtime is after epoch,
// so fileChangedSince correctly detects the absent -> present transition AND
// the mtime comparison succeeds even without artificially inflated Chtimes.
func TestEmptyWorkspaceBaselineDetectsNewFiles(t *testing.T) {
// Empty workspace: no bootstrap files, no memory, no skills content.
tmpDir := setupWorkspace(t, nil)
defer os.RemoveAll(tmpDir)
cb := NewContextBuilder(tmpDir)
// Build cache — all tracked files are absent, maxMtime falls back to epoch.
sp1 := cb.BuildSystemPromptWithCache()
// Create a bootstrap file with natural mtime (no Chtimes manipulation).
// The file's mtime should be the current wall-clock time, which is
// strictly after time.Unix(1, 0).
soulPath := filepath.Join(tmpDir, "SOUL.md")
if err := os.WriteFile(soulPath, []byte("# Soul\nNewly created."), 0o644); err != nil {
t.Fatal(err)
}
// Cache should detect the new file via existedAtCache (absent -> present).
cb.systemPromptMutex.RLock()
changed := cb.sourceFilesChangedLocked()
cb.systemPromptMutex.RUnlock()
if !changed {
t.Fatal("sourceFilesChangedLocked should detect newly created file on empty workspace")
}
sp2 := cb.BuildSystemPromptWithCache()
if !strings.Contains(sp2, "Newly created") {
t.Error("rebuilt prompt should contain new file content")
}
if sp1 == sp2 {
t.Error("cache should have been invalidated after file creation")
}
}
// BenchmarkBuildMessagesWithCache measures caching performance.
func BenchmarkBuildMessagesWithCache(b *testing.B) {
tmpDir, _ := os.MkdirTemp("", "picoclaw-bench-*")
defer os.RemoveAll(tmpDir)
os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755)
os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755)
for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} {
os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644)
}
cb := NewContextBuilder(tmpDir)
history := []providers.Message{
{Role: "user", Content: "previous message"},
{Role: "assistant", Content: "previous response"},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = cb.BuildMessages(history, "summary", "new message", nil, "cli", "test")
}

View file

@ -12,11 +12,9 @@ func msg(role, content string) providers.Message {
func assistantWithTools(toolIDs ...string) providers.Message {
calls := make([]providers.ToolCall, len(toolIDs))
for i, id := range toolIDs {
calls[i] = providers.ToolCall{ID: id, Type: "function"}
}
return providers.Message{Role: "assistant", ToolCalls: calls}
}
@ -26,13 +24,11 @@ func toolResult(id string) providers.Message {
func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
result := sanitizeHistoryForProvider(nil)
if len(result) != 0 {
t.Fatalf("expected empty, got %d messages", len(result))
}
result = sanitizeHistoryForProvider([]providers.Message{})
if len(result) != 0 {
t.Fatalf("expected empty, got %d messages", len(result))
}
@ -41,228 +37,170 @@ func TestSanitizeHistoryForProvider_EmptyHistory(t *testing.T) {
func TestSanitizeHistoryForProvider_SingleToolCall(t *testing.T) {
history := []providers.Message{
msg("user", "hello"),
assistantWithTools("A"),
toolResult("A"),
msg("assistant", "done"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 4 {
t.Fatalf("expected 4 messages, got %d", len(result))
}
assertRoles(t, result, "user", "assistant", "tool", "assistant")
}
func TestSanitizeHistoryForProvider_MultiToolCalls(t *testing.T) {
history := []providers.Message{
msg("user", "do two things"),
assistantWithTools("A", "B"),
toolResult("A"),
toolResult("B"),
msg("assistant", "both done"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 5 {
t.Fatalf("expected 5 messages, got %d: %+v", len(result), roles(result))
}
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant")
}
func TestSanitizeHistoryForProvider_AssistantToolCallAfterPlainAssistant(t *testing.T) {
history := []providers.Message{
msg("user", "hi"),
msg("assistant", "thinking"),
assistantWithTools("A"),
toolResult("A"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 2 {
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
}
assertRoles(t, result, "user", "assistant")
}
func TestSanitizeHistoryForProvider_OrphanedLeadingTool(t *testing.T) {
history := []providers.Message{
toolResult("A"),
msg("user", "hello"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 1 {
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
}
assertRoles(t, result, "user")
}
func TestSanitizeHistoryForProvider_ToolAfterUserDropped(t *testing.T) {
history := []providers.Message{
msg("user", "hello"),
toolResult("A"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 1 {
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
}
assertRoles(t, result, "user")
}
func TestSanitizeHistoryForProvider_ToolAfterAssistantNoToolCalls(t *testing.T) {
history := []providers.Message{
msg("user", "hello"),
msg("assistant", "hi"),
toolResult("A"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 2 {
t.Fatalf("expected 2 messages, got %d: %+v", len(result), roles(result))
}
assertRoles(t, result, "user", "assistant")
}
func TestSanitizeHistoryForProvider_AssistantToolCallAtStart(t *testing.T) {
history := []providers.Message{
assistantWithTools("A"),
toolResult("A"),
msg("user", "hello"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 1 {
t.Fatalf("expected 1 message, got %d: %+v", len(result), roles(result))
}
assertRoles(t, result, "user")
}
func TestSanitizeHistoryForProvider_MultiToolCallsThenNewRound(t *testing.T) {
history := []providers.Message{
msg("user", "do two things"),
assistantWithTools("A", "B"),
toolResult("A"),
toolResult("B"),
msg("assistant", "done"),
msg("user", "hi"),
assistantWithTools("C"),
toolResult("C"),
msg("assistant", "done again"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 9 {
t.Fatalf("expected 9 messages, got %d: %+v", len(result), roles(result))
}
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "user", "assistant", "tool", "assistant")
}
func TestSanitizeHistoryForProvider_ConsecutiveMultiToolRounds(t *testing.T) {
history := []providers.Message{
msg("user", "start"),
assistantWithTools("A", "B"),
toolResult("A"),
toolResult("B"),
assistantWithTools("C", "D"),
toolResult("C"),
toolResult("D"),
msg("assistant", "all done"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 8 {
t.Fatalf("expected 8 messages, got %d: %+v", len(result), roles(result))
}
assertRoles(t, result, "user", "assistant", "tool", "tool", "assistant", "tool", "tool", "assistant")
}
func TestSanitizeHistoryForProvider_PlainConversation(t *testing.T) {
history := []providers.Message{
msg("user", "hello"),
msg("assistant", "hi"),
msg("user", "how are you"),
msg("assistant", "fine"),
}
result := sanitizeHistoryForProvider(history)
if len(result) != 4 {
t.Fatalf("expected 4 messages, got %d", len(result))
}
assertRoles(t, result, "user", "assistant", "user", "assistant")
}
func roles(msgs []providers.Message) []string {
r := make([]string, len(msgs))
for i, m := range msgs {
r[i] = m.Role
}
return r
}
func assertRoles(t *testing.T, msgs []providers.Message, expected ...string) {
t.Helper()
if len(msgs) != len(expected) {
t.Fatalf("role count mismatch: got %v, want %v", roles(msgs), expected)
}
for i, exp := range expected {
if msgs[i].Role != exp {
t.Errorf("message[%d]: got role %q, want %q", i, msgs[i].Role, exp)

View file

@ -5,6 +5,7 @@ import (
"log"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
@ -17,126 +18,120 @@ import (
)
// AgentInstance represents a fully configured agent with its own workspace,
// session manager, context builder, and tool registry.
type AgentInstance struct {
ID string
ID string
Name string
Model string
Fallbacks []string
Workspace string
MaxIterations int
TaskReminderInterval int
MaxTokens int
Temperature float64
ThinkingLevel ThinkingLevel
ContextWindow int
SummarizeMessageThreshold int
SummarizeTokenPercent int
Provider providers.LLMProvider
Sessions *session.LegacyAdapter
ContextBuilder *ContextBuilder
Tools *tools.ToolRegistry
Subagents *config.SubagentsConfig
SkillsFilter []string
Candidates []providers.FallbackCandidate
PlanModel string
PlanFallbacks []string
PlanCandidates []providers.FallbackCandidate
Name string
Model string
Fallbacks []string
Workspace string
MaxIterations int
TaskReminderInterval int
MaxTokens int
Temperature float64
ContextWindow int
Provider providers.LLMProvider
Sessions *session.LegacyAdapter
ContextBuilder *ContextBuilder
Tools *tools.ToolRegistry
Subagents *config.SubagentsConfig
SkillsFilter []string
Candidates []providers.FallbackCandidate
PlanModel string
PlanFallbacks []string
PlanCandidates []providers.FallbackCandidate
// Router is non-nil when model routing is configured and the light model
// was successfully resolved. It scores each incoming message and decides
// whether to route to LightCandidates or stay with Candidates.
Router *routing.Router
// LightCandidates holds the resolved provider candidates for the light model.
// Pre-computed at agent creation to avoid repeated model_list lookups at runtime.
LightCandidates []providers.FallbackCandidate
// SubagentMgr is set during registerSharedTools when orchestration is enabled.
// Used by runAgentLoop to wait for spawned subagents before worktree cleanup.
SubagentMgr *tools.SubagentManager
// Interview staleness tracking: consecutive turns where MEMORY.md was not updated.
interviewStaleCount int
interviewMemoryLen int
interviewMemoryLen int
// Per-session worktree isolation
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
worktrees map[string]*git.WorktreeInfo // sessionKey → worktree
worktreeMu sync.RWMutex
}
// NewAgentInstance creates an agent instance from config.
// Close releases resources held by the agent instance.
// If the provider implements StatefulProvider, its Close method is called.
func (ai *AgentInstance) Close() error {
if sp, ok := ai.Provider.(providers.StatefulProvider); ok {
sp.Close()
}
return nil
}
// NewAgentInstance creates an agent instance from config.
func NewAgentInstance(
agentCfg *config.AgentConfig,
defaults *config.AgentDefaults,
cfg *config.Config,
provider providers.LLMProvider,
) *AgentInstance {
workspace := resolveAgentWorkspace(agentCfg, defaults)
os.MkdirAll(workspace, 0o755)
model := resolveAgentModel(agentCfg, defaults)
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
restrict := defaults.RestrictToWorkspace
readRestrict := restrict && !defaults.AllowReadOutsideWorkspace
// Compile path whitelist patterns from config.
allowReadPaths := compilePatterns(cfg.Tools.AllowReadPaths)
allowWritePaths := compilePatterns(cfg.Tools.AllowWritePaths)
toolsRegistry := tools.NewToolRegistry()
toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
if err != nil {
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
if cfg.Tools.IsToolEnabled("read_file") {
maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize
toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths))
}
if cfg.Tools.IsToolEnabled("write_file") {
toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths))
}
if cfg.Tools.IsToolEnabled("list_dir") {
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
}
if cfg.Tools.IsToolEnabled("exec") {
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
if err != nil {
log.Fatalf("Critical error: unable to initialize exec tool: %v", err)
}
toolsRegistry.Register(execTool)
}
toolsRegistry.Register(execTool)
toolsRegistry.Register(tools.NewBgMonitorTool(execTool))
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
if cfg.Tools.IsToolEnabled("edit_file") {
toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths))
}
if cfg.Tools.IsToolEnabled("append_file") {
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
}
toolsRegistry.Register(tools.NewLogsTool())
toolsRegistry.Register(tools.NewGitPushTool())
toolsRegistry.Register(tools.NewCreatePRTool())
dbPath := filepath.Join(workspace, "sessions.db")
store, err := session.OpenSQLiteStore(dbPath)
if err != nil {
log.Fatalf("open session store: %v", err)
}
jsonDir := filepath.Join(workspace, "sessions")
if n, merr := session.MigrateJSONSessions(jsonDir, store); merr != nil {
log.Printf("session migration: %d migrated, error: %v", n, merr)
} else if n > 0 {
@ -151,28 +146,25 @@ func NewAgentInstance(
sessionsManager := session.NewLegacyAdapter(store)
contextBuilder := NewContextBuilder(workspace)
mcpDiscoveryActive := cfg.Tools.MCP.Enabled && cfg.Tools.MCP.Discovery.Enabled
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
)
agentID := routing.DefaultAgentID
agentName := ""
var subagents *config.SubagentsConfig
var skillsFilter []string
if agentCfg != nil {
agentID = routing.NormalizeAgentID(agentCfg.ID)
agentName = agentCfg.Name
subagents = agentCfg.Subagents
skillsFilter = agentCfg.Skills
}
// Apply defaults.Orchestration: if the flag is set, ensure orchestration is enabled.
if defaults.Orchestration {
if subagents == nil {
subagents = &config.SubagentsConfig{Enabled: true}
@ -182,54 +174,59 @@ func NewAgentInstance(
}
maxIter := defaults.MaxToolIterations
if maxIter == 0 {
maxIter = 20
}
reminderInterval := defaults.TaskReminderInterval
if reminderInterval == 0 {
reminderInterval = 5
}
maxTokens := defaults.MaxTokens
if maxTokens == 0 {
maxTokens = 8192
}
temperature := 0.7
if defaults.Temperature != nil {
temperature = *defaults.Temperature
}
// Resolve fallback candidates
var thinkingLevelStr string
if mc, err := cfg.GetModelConfig(model); err == nil {
thinkingLevelStr = mc.ThinkingLevel
}
thinkingLevel := parseThinkingLevel(thinkingLevelStr)
modelCfg := providers.ModelConfig{
Primary: model,
Fallbacks: fallbacks,
summarizeMessageThreshold := defaults.SummarizeMessageThreshold
if summarizeMessageThreshold == 0 {
summarizeMessageThreshold = 20
}
summarizeTokenPercent := defaults.SummarizeTokenPercent
if summarizeTokenPercent == 0 {
summarizeTokenPercent = 75
}
// Resolve fallback candidates
modelCfg := providers.ModelConfig{
Primary: model,
Fallbacks: fallbacks,
}
resolveFromModelList := func(raw string) (string, bool) {
ensureProtocol := func(model string) string {
model = strings.TrimSpace(model)
if model == "" {
return ""
}
if strings.Contains(model, "/") {
return model
}
return "openai/" + model
}
raw = strings.TrimSpace(raw)
if raw == "" {
return "", false
}
@ -241,17 +238,13 @@ func NewAgentInstance(
for i := range cfg.ModelList {
fullModel := strings.TrimSpace(cfg.ModelList[i].Model)
if fullModel == "" {
continue
}
if fullModel == raw {
return ensureProtocol(fullModel), true
}
_, modelID := providers.ExtractProtocol(fullModel)
if modelID == raw {
return ensureProtocol(fullModel), true
}
@ -264,76 +257,73 @@ func NewAgentInstance(
candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList)
// Resolve plan model (for interviewing/review phases)
planModel := resolvePlanModel(agentCfg, defaults)
planFallbacks := resolvePlanFallbacks(agentCfg, defaults)
var planCandidates []providers.FallbackCandidate
if planModel != "" {
planModelCfg := providers.ModelConfig{
Primary: planModel,
Primary: planModel,
Fallbacks: planFallbacks,
}
planCandidates = providers.ResolveCandidates(planModelCfg, defaults.Provider)
}
// Model routing setup: pre-resolve light model candidates at creation time
// to avoid repeated model_list lookups on every incoming message.
var router *routing.Router
var lightCandidates []providers.FallbackCandidate
if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" {
lightModelCfg := providers.ModelConfig{Primary: rc.LightModel}
resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList)
if len(resolved) > 0 {
router = routing.New(routing.RouterConfig{
LightModel: rc.LightModel,
Threshold: rc.Threshold,
})
lightCandidates = resolved
} else {
log.Printf("routing: light_model %q not found in model_list — routing disabled for agent %q",
rc.LightModel, agentID)
}
}
// Startup cleanup: prune orphaned worktrees
worktreesDir := filepath.Join(workspace, ".worktrees")
if repoRoot := git.FindRepoRoot(workspace); repoRoot != "" {
git.PruneOrphaned(repoRoot, worktreesDir)
}
return &AgentInstance{
ID: agentID,
Name: agentName,
Model: model,
Fallbacks: fallbacks,
Workspace: workspace,
MaxIterations: maxIter,
TaskReminderInterval: reminderInterval,
MaxTokens: maxTokens,
Temperature: temperature,
ContextWindow: maxTokens,
Provider: provider,
Sessions: sessionsManager,
ContextBuilder: contextBuilder,
Tools: toolsRegistry,
Subagents: subagents,
SkillsFilter: skillsFilter,
Candidates: candidates,
PlanModel: planModel,
PlanFallbacks: planFallbacks,
PlanCandidates: planCandidates,
ID: agentID,
Name: agentName,
Model: model,
Fallbacks: fallbacks,
Workspace: workspace,
MaxIterations: maxIter,
TaskReminderInterval: reminderInterval,
MaxTokens: maxTokens,
Temperature: temperature,
ThinkingLevel: thinkingLevel,
ContextWindow: maxTokens,
SummarizeMessageThreshold: summarizeMessageThreshold,
SummarizeTokenPercent: summarizeTokenPercent,
Provider: provider,
Sessions: sessionsManager,
ContextBuilder: contextBuilder,
Tools: toolsRegistry,
Subagents: subagents,
SkillsFilter: skillsFilter,
Candidates: candidates,
PlanModel: planModel,
PlanFallbacks: planFallbacks,
PlanCandidates: planCandidates,
Router: router,
LightCandidates: lightCandidates,
}
}
// resolveAgentWorkspace determines the workspace directory for an agent.
func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" {
return expandHome(strings.TrimSpace(agentCfg.Workspace))
@ -344,75 +334,58 @@ func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentD
}
home, _ := os.UserHomeDir()
id := routing.NormalizeAgentID(agentCfg.ID)
return filepath.Join(home, ".picoclaw", "workspace-"+id)
}
// resolveAgentModel resolves the primary model for an agent.
func resolveAgentModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
if agentCfg != nil && agentCfg.Model != nil && strings.TrimSpace(agentCfg.Model.Primary) != "" {
return strings.TrimSpace(agentCfg.Model.Primary)
}
return defaults.GetModelName()
}
// resolveAgentFallbacks resolves the fallback models for an agent.
func resolveAgentFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
if agentCfg != nil && agentCfg.Model != nil && agentCfg.Model.Fallbacks != nil {
return agentCfg.Model.Fallbacks
}
return defaults.ModelFallbacks
}
// resolvePlanModel resolves the plan model for an agent (used during interviewing/review phases).
func resolvePlanModel(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string {
if agentCfg != nil && agentCfg.PlanModel != nil && strings.TrimSpace(agentCfg.PlanModel.Primary) != "" {
return strings.TrimSpace(agentCfg.PlanModel.Primary)
}
return defaults.PlanModel
}
// resolvePlanFallbacks resolves the plan model fallbacks for an agent.
func resolvePlanFallbacks(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) []string {
if agentCfg != nil && agentCfg.PlanModel != nil && agentCfg.PlanModel.Fallbacks != nil {
return agentCfg.PlanModel.Fallbacks
}
return defaults.PlanModelFallbacks
}
// ActivateWorktree creates a worktree for a session.
// projectDir is the git repository to create the worktree in.
// If empty, falls back to ai.Workspace.
// Worktree path: <workspace>/.worktrees/<branch-basename>/
func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir string) (*git.WorktreeInfo, error) {
if projectDir == "" {
projectDir = ai.Workspace
}
repoRoot := git.FindRepoRoot(projectDir)
if repoRoot == "" {
return nil, fmt.Errorf("directory is not a git repository: %s", projectDir)
}
branchName := git.SanitizeBranchName(taskName)
baseName := git.BranchBaseName(branchName)
wtPath := filepath.Join(ai.Workspace, ".worktrees", baseName)
wt, err := git.CreateWorktree(repoRoot, wtPath, branchName)
@ -421,29 +394,22 @@ func (ai *AgentInstance) ActivateWorktree(sessionKey, taskName, projectDir strin
}
ai.worktreeMu.Lock()
if ai.worktrees == nil {
ai.worktrees = make(map[string]*git.WorktreeInfo)
}
ai.worktrees[sessionKey] = wt
ai.worktreeMu.Unlock()
return wt, nil
}
// DeactivateWorktree safe-disposes the session's worktree.
func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discard bool) (*git.DisposeResult, error) {
ai.worktreeMu.Lock()
wt, ok := ai.worktrees[sessionKey]
if ok {
delete(ai.worktrees, sessionKey)
}
ai.worktreeMu.Unlock()
if !ok || wt == nil {
@ -451,72 +417,70 @@ func (ai *AgentInstance) DeactivateWorktree(sessionKey, commitMsg string, discar
}
repoRoot := git.FindRepoRoot(ai.Workspace)
if repoRoot == "" {
return nil, fmt.Errorf("workspace is not a git repository")
}
// Even on discard, SafeDispose auto-commits first for safety
if commitMsg != "" && git.HasUncommittedChanges(wt.Path) {
_ = git.AutoCommit(wt.Path, commitMsg)
}
result := git.SafeDispose(repoRoot, wt)
return &result, nil
}
// GetWorktree returns the session's active worktree, or nil.
func (ai *AgentInstance) GetWorktree(sessionKey string) *git.WorktreeInfo {
ai.worktreeMu.RLock()
defer ai.worktreeMu.RUnlock()
return ai.worktrees[sessionKey]
}
// IsInWorktree returns true if the session has an active worktree.
func (ai *AgentInstance) IsInWorktree(sessionKey string) bool {
return ai.GetWorktree(sessionKey) != nil
}
// EffectiveWorkspace returns worktree path for session, or original Workspace.
func (ai *AgentInstance) EffectiveWorkspace(sessionKey string) string {
if wt := ai.GetWorktree(sessionKey); wt != nil {
return wt.Path
}
return ai.Workspace
}
// GetWorktreeBranch returns the branch name for the session's worktree, or "".
func (ai *AgentInstance) GetWorktreeBranch(sessionKey string) string {
if wt := ai.GetWorktree(sessionKey); wt != nil {
return wt.Branch
}
return ""
}
func compilePatterns(patterns []string) []*regexp.Regexp {
compiled := make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns {
re, err := regexp.Compile(p)
if err != nil {
fmt.Printf("Warning: invalid path pattern %q: %v\n", p, err)
continue
}
compiled = append(compiled, re)
}
return compiled
}
func expandHome(path string) string {
if path == "" {
return path
}
if path[0] == '~' {
home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' {
return home + path[1:]
}
return home
}
return path
}

View file

@ -0,0 +1,53 @@
package agent
import (
"os"
"testing"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "glm-5",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "glm-5",
Model: "glm-5",
APIBase: "https://api.z.ai/api/coding/paas/v4",
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
if agent.Candidates[0].Provider != "openai" {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
}
if agent.Candidates[0].Model != "glm-5" {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
}
}

View file

@ -12,35 +12,28 @@ func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
},
},
}
configuredTemp := 1.0
cfg.Agents.Defaults.Temperature = &configuredTemp
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.MaxTokens != 1234 {
t.Fatalf("MaxTokens = %d, want %d", agent.MaxTokens, 1234)
}
if agent.Temperature != 1.0 {
t.Fatalf("Temperature = %f, want %f", agent.Temperature, 1.0)
}
@ -51,29 +44,23 @@ func TestNewAgentInstance_DefaultsTemperatureWhenZero(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
},
},
}
configuredTemp := 0.0
cfg.Agents.Defaults.Temperature = &configuredTemp
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.Temperature != 0.0 {
@ -86,25 +73,20 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 1234,
MaxToolIterations: 5,
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.Temperature != 0.7 {
@ -113,91 +95,68 @@ func TestNewAgentInstance_DefaultsTemperatureWhenUnset(t *testing.T) {
}
func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "step-3.5-flash",
},
tests := []struct {
name string
aliasName string
modelName string
apiBase string
wantProvider string
wantModel string
}{
{
name: "alias with provider prefix",
aliasName: "step-3.5-flash",
modelName: "openrouter/stepfun/step-3.5-flash:free",
apiBase: "https://openrouter.ai/api/v1",
wantProvider: "openrouter",
wantModel: "stepfun/step-3.5-flash:free",
},
ModelList: []config.ModelConfig{
{
ModelName: "step-3.5-flash",
Model: "openrouter/stepfun/step-3.5-flash:free",
APIBase: "https://openrouter.ai/api/v1",
},
{
name: "alias without provider prefix",
aliasName: "glm-5",
modelName: "glm-5",
apiBase: "https://api.z.ai/api/coding/paas/v4",
wantProvider: "openai",
wantModel: "glm-5",
},
}
provider := &mockProvider{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: tt.aliasName,
},
},
ModelList: []config.ModelConfig{
{
ModelName: tt.aliasName,
Model: tt.modelName,
APIBase: tt.apiBase,
},
},
}
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if agent.Candidates[0].Provider != "openrouter" {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openrouter")
}
if agent.Candidates[0].Model != "stepfun/step-3.5-flash:free" {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "stepfun/step-3.5-flash:free")
}
}
func TestNewAgentInstance_ResolveCandidatesFromModelListAliasWithoutProtocol(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-instance-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "glm-5",
},
},
ModelList: []config.ModelConfig{
{
ModelName: "glm-5",
Model: "glm-5",
APIBase: "https://api.z.ai/api/coding/paas/v4",
},
},
}
provider := &mockProvider{}
agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
if agent.Candidates[0].Provider != "openai" {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, "openai")
}
if agent.Candidates[0].Model != "glm-5" {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, "glm-5")
if len(agent.Candidates) != 1 {
t.Fatalf("len(Candidates) = %d, want 1", len(agent.Candidates))
}
if agent.Candidates[0].Provider != tt.wantProvider {
t.Fatalf("candidate provider = %q, want %q", agent.Candidates[0].Provider, tt.wantProvider)
}
if agent.Candidates[0].Model != tt.wantModel {
t.Fatalf("candidate model = %q, want %q", agent.Candidates[0].Model, tt.wantModel)
}
})
}
}

File diff suppressed because it is too large Load diff

717
pkg/agent/loop_commands.go Normal file
View file

@ -0,0 +1,717 @@
package agent
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/stats"
)
func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/") {
return "", false
}
parts := strings.Fields(content)
if len(parts) == 0 {
return "", false
}
cmd := parts[0]
args := parts[1:]
switch cmd {
case "/show":
if len(args) < 1 {
return "Usage: /show [model|channel|agents]", true
}
switch args[0] {
case "model":
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
return "No default agent configured", true
}
return fmt.Sprintf("Current model: %s", defaultAgent.Model), true
case "channel":
return fmt.Sprintf("Current channel: %s", msg.Channel), true
case "agents":
agentIDs := al.registry.ListAgentIDs()
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
default:
return fmt.Sprintf("Unknown show target: %s", args[0]), true
}
case "/list":
if len(args) < 1 {
return "Usage: /list [models|channels|agents]", true
}
switch args[0] {
case "models":
return "Available models: configured in config.json per agent", true
case "channels":
if al.channelManager == nil {
return "Channel manager not initialized", true
}
channels := al.channelManager.GetEnabledChannels()
if len(channels) == 0 {
return "No channels enabled", true
}
return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true
case "agents":
agentIDs := al.registry.ListAgentIDs()
return fmt.Sprintf("Registered agents: %s", strings.Join(agentIDs, ", ")), true
default:
return fmt.Sprintf("Unknown list target: %s", args[0]), true
}
case "/switch":
if len(args) < 3 || args[1] != "to" {
return "Usage: /switch [model|channel] to <name>", true
}
target := args[0]
value := args[2]
switch target {
case "model":
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
return "No default agent configured", true
}
oldModel := defaultAgent.Model
defaultAgent.Model = value
return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true
case "channel":
if al.channelManager == nil {
return "Channel manager not initialized", true
}
if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" {
return fmt.Sprintf("Channel '%s' not found or not enabled", value), true
}
return fmt.Sprintf("Switched target channel to %s", value), true
default:
return fmt.Sprintf("Unknown switch target: %s", target), true
}
case "/session":
return al.handleSessionCommand(args, msg.SessionKey), true
case "/skills":
return al.handleSkillsCommand(), true
case "/plan":
resp, handled := al.handlePlanCommand(args, msg.SessionKey)
if handled {
al.notifyStateChange()
}
return resp, handled
case "/heartbeat":
resp, handled := al.handleHeartbeatCommand(args, msg)
if handled {
al.notifyStateChange()
}
return resp, handled
}
return "", false
}
func (al *AgentLoop) handleHeartbeatCommand(args []string, msg bus.InboundMessage) (string, bool) {
if len(args) == 0 {
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
}
if args[0] != "thread" {
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
}
if len(args) < 2 {
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
}
if msg.Channel != "telegram" {
return "/heartbeat thread is only supported from Telegram chats.", true
}
baseChatID, currentThreadID := splitChatAndThread(msg.ChatID)
if baseChatID == "" {
return "Unable to detect Telegram chat ID for heartbeat routing.", true
}
arg := strings.ToLower(strings.TrimSpace(args[1]))
var threadID int
var err error
switch arg {
case "off", "disable", "clear":
threadID = 0
case "here", "this":
if currentThreadID <= 0 {
return "Current Telegram message is not in a thread. Usage: /heartbeat thread <thread_id>", true
}
threadID = currentThreadID
default:
threadID, err = strconv.Atoi(arg)
if err != nil || threadID < 0 {
return "Usage: /heartbeat thread [here|off|<thread_id>]", true
}
}
al.cfg.Channels.Telegram.HeartbeatThreadID = threadID
if al.state != nil {
_ = al.state.SetHeartbeatTarget(fmt.Sprintf("telegram:%s", baseChatID))
}
if al.onHeartbeatThreadUpdate != nil {
al.onHeartbeatThreadUpdate(threadID)
}
if al.saveConfig != nil {
if err := al.saveConfig(al.cfg); err != nil {
return fmt.Sprintf("Failed to persist config.json: %v", err), true
}
}
if threadID == 0 {
return fmt.Sprintf("Heartbeat thread routing disabled for chat %s and saved to config.json.", baseChatID), true
}
return fmt.Sprintf("Heartbeat thread set to %d for chat %s and saved to config.json.", threadID, baseChatID), true
}
func splitChatAndThread(chatID string) (baseChatID string, threadID int) {
baseChatID = strings.TrimSpace(chatID)
if baseChatID == "" {
return "", 0
}
if slash := strings.Index(baseChatID, "/"); slash >= 0 {
threadPart := strings.TrimSpace(baseChatID[slash+1:])
baseChatID = strings.TrimSpace(baseChatID[:slash])
if tid, err := strconv.Atoi(threadPart); err == nil && tid > 0 {
threadID = tid
}
}
return baseChatID, threadID
}
// handleSessionCommand dispatches /session subcommands.
func (al *AgentLoop) handleSessionCommand(args []string, sessionKey string) string {
sub := ""
if len(args) > 0 {
sub = strings.ToLower(strings.TrimSpace(args[0]))
}
switch sub {
case "list":
return al.handleSessionList()
case "graph":
return al.handleSessionGraph()
case "fork":
return al.handleSessionFork(args[1:], sessionKey)
case "reset":
if al.stats == nil {
return "Stats tracking is disabled."
}
al.stats.Reset()
return "Session statistics have been reset."
default:
return al.handleSessionStats()
}
}
func (al *AgentLoop) handleSessionStats() string {
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
// Session DAG summary
sessions, _ := store.List(nil)
var sb strings.Builder
fmt.Fprintf(&sb, "Sessions: %d in store\n", len(sessions))
if len(sessions) > 0 {
active, completed := 0, 0
for _, s := range sessions {
switch s.Status {
case "active":
active++
case "completed":
completed++
}
}
fmt.Fprintf(&sb, " active=%d completed=%d\n", active, completed)
}
sb.WriteString("\nUse: /session list | graph | fork [label]\n")
// Token stats if available
if al.stats != nil {
s := al.stats.GetStats()
fmt.Fprintf(&sb,
"\nToken Stats — Today (%s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)\n"+
"All time (since %s):\n Prompts: %d LLM calls: %d Tokens: %s (in: %s, out: %s)",
s.Today.Date,
s.Today.Prompts,
s.Today.Requests,
stats.FormatTokenCount(s.Today.TotalTokens),
stats.FormatTokenCount(s.Today.PromptTokens),
stats.FormatTokenCount(s.Today.CompletionTokens),
s.Since.Format("2006-01-02"),
s.TotalPrompts,
s.TotalRequests,
stats.FormatTokenCount(s.TotalTokens),
stats.FormatTokenCount(s.TotalPromptTokens),
stats.FormatTokenCount(s.TotalCompletionTokens),
)
}
return sb.String()
}
// shortSessionKey truncates long session keys for display.
func shortSessionKey(key string) string {
parts := strings.Split(key, ":")
if len(parts) > 2 {
return strings.Join(parts[2:], ":")
}
return key
}
func (al *AgentLoop) handleSessionList() string {
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
sessions, err := store.List(nil)
if err != nil {
return fmt.Sprintf("Error listing sessions: %v", err)
}
if len(sessions) == 0 {
return "No sessions in store."
}
var sb strings.Builder
fmt.Fprintf(&sb, "Sessions (%d)\n", len(sessions))
for _, s := range sessions {
age := time.Since(s.UpdatedAt).Truncate(time.Second)
label := s.Label
if label == "" {
label = shortSessionKey(s.Key)
}
parent := ""
if s.ParentKey != "" {
parent = " parent=" + shortSessionKey(s.ParentKey)
}
fmt.Fprintf(&sb, "- %s [%s] (%s) turns=%d%s\n",
label, s.Status, age, s.TurnCount, parent)
}
return sb.String()
}
func (al *AgentLoop) handleSessionGraph() string {
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
sessions, err := store.List(nil)
if err != nil {
return fmt.Sprintf("Error listing sessions: %v", err)
}
if len(sessions) == 0 {
return "No sessions in store."
}
// Build parent→children map and find roots
byKey := make(map[string]*session.SessionInfo, len(sessions))
children := make(map[string][]string)
var roots []string
for _, s := range sessions {
byKey[s.Key] = s
if s.ParentKey == "" {
roots = append(roots, s.Key)
} else {
children[s.ParentKey] = append(children[s.ParentKey], s.Key)
}
}
var sb strings.Builder
sb.WriteString("Session Graph\n")
for i, root := range roots {
last := i == len(roots)-1
printSessionTree(&sb, root, byKey, children, "", last)
}
return sb.String()
}
func printSessionTree(
sb *strings.Builder,
key string,
byKey map[string]*session.SessionInfo,
children map[string][]string,
prefix string,
last bool,
) {
s := byKey[key]
if s == nil {
return
}
connector := "├── "
if last {
connector = "└── "
}
icon := "●"
if s.Status == "completed" {
icon = "✓"
}
label := s.Label
if label == "" {
label = shortSessionKey(s.Key)
}
fmt.Fprintf(sb, "%s%s%s %s (turns=%d)\n", prefix, connector, icon, label, s.TurnCount)
childPrefix := prefix + "│ "
if last {
childPrefix = prefix + " "
}
kids := children[key]
for i, childKey := range kids {
printSessionTree(sb, childKey, byKey, children, childPrefix, i == len(kids)-1)
}
}
func (al *AgentLoop) handleSessionFork(args []string, sessionKey string) string {
if sessionKey == "" {
return "Cannot fork: no active session key."
}
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
label := "fork"
if len(args) > 0 {
label = strings.Join(args, " ")
}
childKey := sessionKey + ":fork:" + time.Now().Format("20060102T150405")
err := store.Fork(sessionKey, childKey, &session.CreateOpts{Label: label})
if err != nil {
return fmt.Sprintf("Fork failed: %v", err)
}
return fmt.Sprintf(
"Forked session\n parent: %s\n child: %s",
shortSessionKey(sessionKey),
shortSessionKey(childKey),
)
}
type SessionGraphNode struct {
Key string `json:"key"`
Label string `json:"label"`
Status string `json:"status"`
Summary string `json:"summary"`
ParentKey string `json:"parent_key"`
ForkTurnID string `json:"fork_turn_id"`
TurnCount int `json:"turn_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// GetSessionGraph returns all sessions as a flat list of graph nodes.
func (al *AgentLoop) GetSessionGraph() []SessionGraphNode {
agent := al.registry.GetDefaultAgent()
store := agent.Sessions.Store()
sessions, err := store.List(nil)
if err != nil {
return nil
}
nodes := make([]SessionGraphNode, 0, len(sessions))
for _, s := range sessions {
nodes = append(nodes, SessionGraphNode{
Key: s.Key,
Label: s.Label,
Status: s.Status,
Summary: s.Summary,
ParentKey: s.ParentKey,
ForkTurnID: s.ForkTurnID,
TurnCount: s.TurnCount,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
})
}
return nodes
}
// expandSkillCommand detects "/skill <name> [message]" and returns:
// - expanded: full content with SKILL.md injected (for LLM)
// - compact: skill name tag + user message only (for history)
// - ok: whether expansion happened
func (al *AgentLoop) expandSkillCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) {
content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/skill ") {
return "", "", false
}
// Parse: /skill <name> [message]
rest := strings.TrimSpace(content[7:]) // len("/skill ") == 7
parts := strings.SplitN(rest, " ", 2)
if len(parts) == 0 || parts[0] == "" {
return "", "", false
}
skillName := parts[0]
userMessage := ""
if len(parts) > 1 {
userMessage = strings.TrimSpace(parts[1])
}
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", "", false
}
skillContent, found := agent.ContextBuilder.LoadSkill(skillName)
if !found {
return "", "", false
}
tag := fmt.Sprintf("[Skill: %s]", skillName)
// Build expanded message: skill instructions + user message (for LLM)
var sb strings.Builder
sb.WriteString(tag)
sb.WriteString("\n\n")
sb.WriteString(skillContent)
if userMessage != "" {
sb.WriteString("\n\n---\n\n")
sb.WriteString(userMessage)
}
// Build compact form: skill name tag + user message only (for history)
compactForm := tag
if userMessage != "" {
compactForm = tag + "\n" + userMessage
}
return sb.String(), compactForm, true
}
// handleSkillsCommand lists all available skills.
func (al *AgentLoop) handleSkillsCommand() string {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "No agent configured."
}
skillsList := agent.ContextBuilder.ListSkills()
if len(skillsList) == 0 {
return "No skills available.\nAdd skills to your workspace/skills/ directory."
}
var sb strings.Builder
sb.WriteString("Available Skills\n\n")
for _, s := range skillsList {
fmt.Fprintf(&sb, "**%s** (%s)\n", s.Name, s.Source)
if s.Description != "" {
fmt.Fprintf(&sb, "```\n%s\n```\n", s.Description)
}
}
sb.WriteString("\nUse: /skill <name> [message]")
return sb.String()
}

2883
pkg/agent/loop_ext_test.go Normal file

File diff suppressed because it is too large Load diff

521
pkg/agent/loop_hooks.go Normal file
View file

@ -0,0 +1,521 @@
package agent
import (
"context"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/orch"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
// iterationHooks contains callbacks that extend the core LLM iteration loop.
// All fields are initialized to no-op defaults by buildHooks, so callers
// never need nil checks.
type iterationHooks struct {
// OnIterationStart is called at the top of each iteration.
// Returns an optional user-role message to inject (e.g. user intervention).
OnIterationStart func(iteration int) (interventionMsg string)
// FilterTools is called after building provider tool definitions,
// before the LLM call. Returns a (possibly filtered) slice.
FilterTools func(defs []providers.ToolDefinition) []providers.ToolDefinition
// SetupStreaming is called before each LLM call to set up streaming
// preview. Returns an onChunk callback and a cleanup function.
SetupStreaming func() (onChunk func(accumulated, reasoning string), cleanup func())
// SelectModel overrides the model and candidates for this call.
// Returns empty string to use defaults.
SelectModel func() (model string, candidates []providers.FallbackCandidate)
// OnPreLLMCall is called just before the LLM call (e.g. orch state reporting).
OnPreLLMCall func()
// OnNoToolCalls is called when the LLM returns no tool calls.
// Returns an optional nudge message and whether to continue the loop.
OnNoToolCalls func(content string, iteration int) (nudge string, continueLoop bool)
// FilterToolCalls is called after normalizing tool calls, before execution.
// Returns the filtered calls and an optional rejection message.
FilterToolCalls func(calls []providers.ToolCall) (filtered []providers.ToolCall, rejectionMsg string)
// OnPreToolExec is called before each tool execution.
// Returns an async callback (may be nil).
OnPreToolExec func(ctx context.Context, tc providers.ToolCall) tools.AsyncCallback
// OnToolExecDone is called after each tool execution with the result.
OnToolExecDone func(tc providers.ToolCall, result *tools.ToolResult, duration time.Duration)
// OnToolsProcessed is called after all tool calls in an iteration
// have been logged and their results built.
OnToolsProcessed func(ctx context.Context, iteration int, toolCalls []providers.ToolCall)
// InjectReminders is called at the end of each iteration to append
// fork-specific reminder messages (task, plan, orch, subagent questions).
InjectReminders func(iteration int, messages *[]providers.Message, lastBlocker string)
// RefreshSystemPrompt is called at the end of each iteration to
// rebuild the system prompt after tool execution may have changed state.
RefreshSystemPrompt func(messages []providers.Message)
}
// defaultHooks returns an iterationHooks with all fields set to no-ops.
func defaultHooks() iterationHooks {
return iterationHooks{
OnIterationStart: func(int) string { return "" },
FilterTools: func(d []providers.ToolDefinition) []providers.ToolDefinition { return d },
SetupStreaming: func() (func(string, string), func()) { return nil, nil },
SelectModel: func() (string, []providers.FallbackCandidate) { return "", nil },
OnPreLLMCall: func() {},
OnNoToolCalls: func(string, int) (string, bool) { return "", false },
FilterToolCalls: func(c []providers.ToolCall) ([]providers.ToolCall, string) { return c, "" },
OnPreToolExec: func(context.Context, providers.ToolCall) tools.AsyncCallback { return nil },
OnToolExecDone: func(providers.ToolCall, *tools.ToolResult, time.Duration) {},
OnToolsProcessed: func(context.Context, int, []providers.ToolCall) {},
InjectReminders: func(int, *[]providers.Message, string) {},
RefreshSystemPrompt: func([]providers.Message) {},
}
}
// buildHooks constructs the hook set based on the current agent state.
// All fork-specific logic is wired here; the core loop only calls hooks.
func (al *AgentLoop) buildHooks(
agent *AgentInstance,
opts processOptions,
task *activeTask,
planSnapshot string,
) iterationHooks {
h := defaultHooks()
isBackground := opts.TaskID != ""
// ── Task tracking ──
if task != nil {
h.OnIterationStart = func(iteration int) string {
task.mu.Lock()
task.Iteration = iteration
task.mu.Unlock()
select {
case msg := <-task.interrupt:
logger.InfoCF("agent", "User intervention injected",
map[string]any{"agent_id": agent.ID, "iteration": iteration})
return "[User Intervention] " + msg
default:
return ""
}
}
h.OnToolExecDone = func(tc providers.ToolCall, result *tools.ToolResult, duration time.Duration) {
updateToolLogResult(task, tc, result, duration)
}
}
// ── Plan mode ──
if planSnapshot != "" {
preUnchecked := -1
if planSnapshot == "executing" {
preUnchecked = strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
}
planMarkNudged := false
if isPlanPreExecution(planSnapshot) {
h.FilterTools = func(defs []providers.ToolDefinition) []providers.ToolDefinition {
return filterInterviewTools(defs)
}
h.FilterToolCalls = func(calls []providers.ToolCall) ([]providers.ToolCall, string) {
allowed := calls[:0]
var rejected []string
for _, tc := range calls {
if isToolAllowedDuringInterview(tc.Name, tc.Arguments) {
allowed = append(allowed, tc)
} else {
rejected = append(rejected, tc.Name)
}
}
if len(rejected) > 0 {
logger.InfoCF("agent", "Interview mode: rejected tool calls",
map[string]any{"agent_id": agent.ID, "rejected": rejected})
}
return allowed, interviewRejectMessage
}
}
h.OnNoToolCalls = func(content string, iteration int) (string, bool) {
if preUnchecked <= 0 || planMarkNudged || planSnapshot != "executing" {
return "", false
}
curUnchecked := strings.Count(agent.ContextBuilder.ReadMemory(), "- [ ]")
if curUnchecked <= 0 {
return "", false
}
planMarkNudged = true
var nudge string
if curUnchecked == preUnchecked {
nudge = fmt.Sprintf("[System] %d unchecked steps remain in MEMORY.md and "+
"none were marked [x] during this session. "+
"If you completed any steps, use edit_file to mark them [x] now. "+
"If steps are still in progress, continue working on them.", curUnchecked)
} else {
nudge = fmt.Sprintf("[System] Progress recorded. %d unchecked steps remain. "+
"Continue working on the next step.", curUnchecked)
}
logger.InfoCF("agent", "Nudging plan execution: continue plan steps",
map[string]any{"agent_id": agent.ID, "iteration": iteration, "unchecked": curUnchecked})
return nudge, true
}
// Plan model selection
if isPlanPreExecution(planSnapshot) && agent.PlanModel != "" {
h.SelectModel = func() (string, []providers.FallbackCandidate) {
logger.InfoCF("agent", "Using plan model",
map[string]any{"agent_id": agent.ID, "plan_model": agent.PlanModel})
return agent.PlanModel, agent.PlanCandidates
}
}
}
// ── Streaming ──
if !constants.IsInternalChannel(opts.Channel) {
h.SetupStreaming = func() (func(string, string), func()) {
return al.setupStreamingHook(opts, task)
}
}
// ── Orchestration ──
if al.orchReporter != orch.Noop {
h.OnPreLLMCall = func() {
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateWaiting, "")
}
// Wrap OnPreToolExec to add orch state reporting
h.OnPreToolExec = func(ctx context.Context, tc providers.ToolCall) tools.AsyncCallback {
al.reporter().ReportStateChange(opts.SessionKey, orch.AgentStateToolCall, tc.Name)
return al.buildAsyncCallback(opts, tc.Name)
}
} else {
// Even without orch, we still need async callback
h.OnPreToolExec = func(ctx context.Context, tc providers.ToolCall) tools.AsyncCallback {
return al.buildAsyncCallback(opts, tc.Name)
}
}
// ── Tool status + session touch ──
if !constants.IsInternalChannel(opts.Channel) && task != nil {
h.OnToolsProcessed = func(ctx context.Context, iteration int, toolCalls []providers.ToolCall) {
al.publishToolStatus(ctx, agent, opts, task, iteration, isBackground, toolCalls)
al.recordSessionTouches(agent, opts, toolCalls)
}
} else {
// Session touch without status publishing
h.OnToolsProcessed = func(ctx context.Context, iteration int, toolCalls []providers.ToolCall) {
al.recordSessionTouches(agent, opts, toolCalls)
}
}
// ── Reminder injection (task + plan + orch + subagent questions) ──
h.InjectReminders = al.buildReminderInjector(agent, opts, task, planSnapshot)
// ── System prompt refresh ──
h.RefreshSystemPrompt = func(messages []providers.Message) {
if touchDir := al.sessions.GetTouchDir(opts.SessionKey); touchDir != "" {
agent.ContextBuilder.SetWorkDir(filepath.Join(agent.Workspace, touchDir))
}
if newPrompt := agent.ContextBuilder.BuildSystemPrompt(); len(messages) > 0 &&
messages[0].Content != newPrompt {
messages[0].Content = newPrompt
al.lastSystemPrompt.Store(newPrompt)
al.promptDirty.Store(false)
}
}
return h
}
// ── Hook helper implementations ──
// setupStreamingHook creates a streaming display goroutine and returns
// the onChunk callback and cleanup function.
func (al *AgentLoop) setupStreamingHook(opts processOptions, task *activeTask) (func(string, string), func()) {
type streamUpdate struct{ accumulated, reasoning string }
streamCh := make(chan streamUpdate, 1)
streamDone := make(chan struct{})
ctx := context.Background() // outlive the caller's context for flush
go func() {
defer close(streamDone)
for up := range streamCh {
display := buildStreamingDisplay(up.accumulated, up.reasoning)
outMsg := bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: display,
}
if opts.Background && opts.TaskID != "" {
outMsg.IsTaskStatus = true
outMsg.TaskID = opts.TaskID
} else {
outMsg.IsStatus = true
}
_ = al.bus.PublishOutbound(ctx, outMsg)
}
}()
onChunk := func(accumulated, reasoning string) {
if task != nil {
task.streamedChunks = true
}
up := streamUpdate{accumulated, reasoning}
select {
case streamCh <- up:
default:
select {
case <-streamCh:
default:
}
select {
case streamCh <- up:
default:
}
}
}
cleanup := func() {
close(streamCh)
<-streamDone
}
return onChunk, cleanup
}
// buildAsyncCallback creates the async tool callback that publishes
// results as system inbound messages.
func (al *AgentLoop) buildAsyncCallback(opts processOptions, toolName string) tools.AsyncCallback {
return func(_ context.Context, result *tools.ToolResult) {
content := result.ForLLM
if content == "" {
content = result.ForUser
}
if content == "" {
return
}
logger.InfoCF("agent", "Async tool completed, publishing to conductor",
map[string]any{"tool": toolName, "content_len": len(content), "is_error": result.IsError})
pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer pubCancel()
_ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("async:%s", toolName),
ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID),
Content: fmt.Sprintf("Async tool '%s' completed.\n\nResult:\n%s", toolName, content),
})
}
}
// updateToolLogResult updates the task's tool log entry with execution result.
func updateToolLogResult(task *activeTask, tc providers.ToolCall, result *tools.ToolResult, duration time.Duration) {
task.mu.Lock()
defer task.mu.Unlock()
// Walk backward to find the matching pending entry
for i := len(task.toolLog) - 1; i >= 0; i-- {
if task.toolLog[i].Result == "\u23F3" {
if result.IsError || result.Err != nil {
task.toolLog[i].Result = fmt.Sprintf("\u2717 %.1fs", duration.Seconds())
if result.Err != nil {
task.toolLog[i].ErrDetail = utils.Truncate(result.Err.Error(), 300)
} else if result.ForLLM != "" {
lines := strings.Split(strings.TrimSpace(result.ForLLM), "\n")
start := len(lines) - 3
if start < 0 {
start = 0
}
task.toolLog[i].ErrDetail = utils.Truncate(
strings.Join(lines[start:], "\n"), 300)
}
entry := task.toolLog[i]
task.lastError = &entry
} else {
task.toolLog[i].Result = fmt.Sprintf("\u2713 %.1fs", duration.Seconds())
}
break
}
}
}
// publishToolStatus adds pending entries to the tool log and publishes
// a rich status update via the message bus.
func (al *AgentLoop) publishToolStatus(
ctx context.Context,
agent *AgentInstance,
opts processOptions,
task *activeTask,
iteration int,
isBackground bool,
toolCalls []providers.ToolCall,
) {
task.mu.Lock()
for _, tc := range toolCalls {
task.toolLog = append(task.toolLog, toolLogEntry{
Name: fmt.Sprintf("[%d] %s", iteration, tc.Name),
ArgsSnip: buildArgsSnippet(tc.Name, tc.Arguments, agent.Workspace),
Result: "\u23F3",
})
if task.projectDir == "" && tc.Name == "exec" {
task.projectDir = extractExecProjectDir(tc.Arguments)
}
switch tc.Name {
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
if p, _ := tc.Arguments["path"].(string); p != "" {
if rel := fileParentRelDir(p, agent.Workspace); rel != "" {
if task.fileCommonDir == "" {
task.fileCommonDir = rel
} else {
task.fileCommonDir = commonDirPrefix(task.fileCommonDir, rel)
}
}
}
}
}
task.mu.Unlock()
statusContent := buildRichStatus(task, isBackground, agent.Workspace)
if isBackground {
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: statusContent,
IsTaskStatus: true,
TaskID: opts.TaskID,
})
} else {
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: opts.Channel,
ChatID: opts.ChatID,
Content: statusContent,
IsStatus: true,
})
}
}
// recordSessionTouches records session activity for heartbeat/plan coordination.
func (al *AgentLoop) recordSessionTouches(
agent *AgentInstance,
opts processOptions,
toolCalls []providers.ToolCall,
) {
for _, tc := range toolCalls {
var detectedDir string
if tc.Name == "exec" {
detectedDir = extractExecProjectDir(tc.Arguments)
}
if detectedDir == "" {
switch tc.Name {
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
if p, _ := tc.Arguments["path"].(string); p != "" {
detectedDir = fileParentRelDir(p, agent.Workspace)
}
}
}
if detectedDir != "" {
meta := &TouchMeta{
ProjectPath: agent.ContextBuilder.GetPlanWorkDir(),
Purpose: utils.Truncate(opts.UserMessage, 80),
Branch: agent.GetWorktreeBranch(opts.SessionKey),
}
if meta.ProjectPath == "" {
meta.ProjectPath = agent.Workspace
}
al.sessions.Touch(opts.SessionKey, opts.Channel, opts.ChatID, detectedDir, meta)
}
}
}
// buildReminderInjector returns a function that injects all end-of-iteration
// reminder messages: task reminders, plan reminders, orch nudges, and
// pending subagent questions.
func (al *AgentLoop) buildReminderInjector(
agent *AgentInstance,
opts processOptions,
task *activeTask,
planSnapshot string,
) func(int, *[]providers.Message, string) {
lastReminderIdx := -1
return func(iteration int, messages *[]providers.Message, lastBlocker string) {
// Task reminder
if shouldInjectReminder(iteration, agent.TaskReminderInterval) && !opts.NoHistory {
if lastReminderIdx >= 0 && lastReminderIdx < len(*messages) {
*messages = append((*messages)[:lastReminderIdx], (*messages)[lastReminderIdx+1:]...)
}
reminderMsg := buildTaskReminder(opts.UserMessage, lastBlocker)
*messages = append(*messages, reminderMsg)
lastReminderIdx = len(*messages) - 1
logger.DebugCF("agent", "Injected task reminder",
map[string]any{"agent_id": agent.ID, "iteration": iteration, "has_blocker": lastBlocker != ""})
}
// Plan reminder
if iteration > 1 && isPlanPreExecution(planSnapshot) {
if reminder, ok := buildPlanReminder(planSnapshot); ok {
*messages = append(*messages, reminder)
logger.DebugCF("agent", "Injected plan reminder",
map[string]any{"agent_id": agent.ID, "iteration": iteration, "plan_status": planSnapshot})
}
}
// Orch nudge
if planSnapshot == "executing" && agent.Subagents != nil && agent.Subagents.Enabled {
if reminder, ok := buildOrchReminder(iteration); ok {
*messages = append(*messages, reminder)
logger.DebugCF("agent", "Injected orchestration nudge",
map[string]any{"agent_id": agent.ID, "iteration": iteration})
}
}
// Subagent questions/plan reviews
if agent.SubagentMgr != nil {
for _, q := range agent.SubagentMgr.PendingQuestions() {
var content string
switch q.Type {
case "plan_review":
content = fmt.Sprintf(
"[Subagent %s submitted a plan for review]:\n%s\nRespond using the review_subagent_plan tool with task_id=%q.",
q.TaskID,
q.Content,
q.TaskID,
)
default:
content = fmt.Sprintf(
"[Subagent %s asks]: %s\nRespond using the answer_subagent tool with task_id=%q.",
q.TaskID, q.Content, q.TaskID,
)
}
*messages = append(*messages, providers.Message{Role: "user", Content: content})
}
}
// Tool log trim
if task != nil {
task.mu.Lock()
if len(task.toolLog) > maxToolLogEntries {
task.toolLog = task.toolLog[len(task.toolLog)-maxToolLogEntries:]
}
task.mu.Unlock()
}
}
}

281
pkg/agent/loop_info.go Normal file
View file

@ -0,0 +1,281 @@
package agent
import (
"encoding/json"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/stats"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
func (al *AgentLoop) GetStartupInfo() map[string]any {
info := make(map[string]any)
agent := al.registry.GetDefaultAgent()
if agent == nil {
return info
}
// Tools info
toolsList := agent.Tools.List()
toolsMap := map[string]any{
"count": len(toolsList),
"names": toolsList,
}
// Report web search provider if registered
if t, ok := agent.Tools.Get("web_search"); ok {
if wst, ok := t.(*tools.WebSearchTool); ok {
toolsMap["web_search_provider"] = wst.ProviderName()
}
}
info["tools"] = toolsMap
// Skills info
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
// Agents info
info["agents"] = map[string]any{
"count": len(al.registry.ListAgentIDs()),
"ids": al.registry.ListAgentIDs(),
}
return info
}
// ListSkills returns all available skills from the default agent.
func (al *AgentLoop) ListSkills() []skills.SkillInfo {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return nil
}
return agent.ContextBuilder.ListSkills()
}
// GetPlanInfo returns plan state from the default agent's memory store.
func (al *AgentLoop) GetPlanInfo() (hasPlan bool, status string, currentPhase, totalPhases int, display string, memory string) {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return false, "", 0, 0, "No agent available.", ""
}
mem := agent.ContextBuilder.Memory()
if mem == nil {
return false, "", 0, 0, "No memory store.", ""
}
hasPlan = mem.HasActivePlan()
status = mem.GetPlanStatus()
currentPhase = mem.GetCurrentPhase()
totalPhases = mem.GetTotalPhases()
display = mem.FormatPlanDisplay()
memory = mem.ReadLongTerm()
return hasPlan, status, currentPhase, totalPhases, display, memory
}
// GetPlanStatus returns the current plan status ("interviewing", "executing", "review", etc.) or "".
func (al *AgentLoop) GetPlanStatus() string {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return ""
}
return agent.ContextBuilder.GetPlanStatus()
}
// GetPlanPhases returns structured phase/step data from the default agent's plan.
func (al *AgentLoop) GetPlanPhases() []PlanPhase {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return nil
}
mem := agent.ContextBuilder.Memory()
if mem == nil {
return nil
}
return mem.GetPlanPhases()
}
// GetActiveSessions returns currently active sessions for the mini app API.
func (al *AgentLoop) GetActiveSessions() []SessionEntry {
return al.sessions.ListActive()
}
// GetSessionStats returns the current session statistics snapshot, or nil if stats tracking is disabled.
func (al *AgentLoop) GetSessionStats() *stats.Stats {
if al.stats == nil {
return nil
}
s := al.stats.GetStats()
return &s
}
// GetContextInfo returns the bootstrap file resolution and directory context for the default agent.
func (al *AgentLoop) GetContextInfo() (workDir, planWorkDir, workspace string, bootstrap []BootstrapFileInfo) {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", "", "", nil
}
workspace = agent.Workspace
planWorkDir = agent.ContextBuilder.GetPlanWorkDir()
// Use the most recent active session's touch_dir (tool-detected project directory)
if active := al.sessions.ListActive(); len(active) > 0 && active[0].TouchDir != "" {
workDir = active[0].TouchDir
} else {
workDir = agent.ContextBuilder.workDir
}
bootstrap = agent.ContextBuilder.ResolveBootstrapPaths()
return workDir, planWorkDir, workspace, bootstrap
}
// GetSystemPrompt returns the system prompt last sent to the LLM.
// If the prompt is dirty (state changed since last capture), it rebuilds
// from current state. Falls back to building if no LLM call has occurred yet.
func (al *AgentLoop) GetSystemPrompt() string {
if !al.promptDirty.Load() {
if v := al.lastSystemPrompt.Load(); v != nil {
return v.(string)
}
}
// Rebuild from current state
agent := al.registry.GetDefaultAgent()
if agent == nil {
return ""
}
prompt := agent.ContextBuilder.BuildSystemPrompt()
al.lastSystemPrompt.Store(prompt)
al.promptDirty.Store(false)
return prompt
}
// formatMessagesForLog formats messages for logging
func formatMessagesForLog(messages []providers.Message) string {
if len(messages) == 0 {
return "[]"
}
var sb strings.Builder
sb.WriteString("[\n")
for i, msg := range messages {
fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
if len(msg.ToolCalls) > 0 {
sb.WriteString(" ToolCalls:\n")
for _, tc := range msg.ToolCalls {
fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
args := tc.Arguments
if len(args) == 0 && tc.Function != nil {
args = tc.Function.Arguments
}
if len(args) > 0 {
argsJSON, _ := json.Marshal(args)
fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(string(argsJSON), 200))
}
}
}
if msg.Content != "" {
content := utils.Truncate(msg.Content, 200)
fmt.Fprintf(&sb, " Content: %s\n", content)
}
if msg.ToolCallID != "" {
fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
}
sb.WriteString("\n")
}
sb.WriteString("]")
return sb.String()
}
// formatToolsForLog formats tool definitions for logging
func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
if len(toolDefs) == 0 {
return "[]"
}
var sb strings.Builder
sb.WriteString("[\n")
for i, tool := range toolDefs {
fmt.Fprintf(&sb, " [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
fmt.Fprintf(&sb, " Description: %s\n", tool.Function.Description)
if len(tool.Function.Parameters) > 0 {
fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(string(tool.Function.Parameters), 200))
}
}
sb.WriteString("]")
return sb.String()
}

184
pkg/agent/loop_mcp.go Normal file
View file

@ -0,0 +1,184 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package agent
import (
"context"
"fmt"
"sync"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/tools"
)
type mcpRuntime struct {
initOnce sync.Once
mu sync.Mutex
manager *mcp.Manager
initErr error
}
func (r *mcpRuntime) setManager(manager *mcp.Manager) {
r.mu.Lock()
r.manager = manager
r.initErr = nil
r.mu.Unlock()
}
func (r *mcpRuntime) setInitErr(err error) {
r.mu.Lock()
r.initErr = err
r.mu.Unlock()
}
func (r *mcpRuntime) getInitErr() error {
r.mu.Lock()
defer r.mu.Unlock()
return r.initErr
}
func (r *mcpRuntime) takeManager() *mcp.Manager {
r.mu.Lock()
defer r.mu.Unlock()
manager := r.manager
r.manager = nil
return manager
}
func (r *mcpRuntime) hasManager() bool {
r.mu.Lock()
defer r.mu.Unlock()
return r.manager != nil
}
// ensureMCPInitialized loads MCP servers/tools once so both Run() and direct
// agent mode share the same initialization path.
func (al *AgentLoop) ensureMCPInitialized(ctx context.Context) error {
if !al.cfg.Tools.IsToolEnabled("mcp") {
return nil
}
al.mcp.initOnce.Do(func() {
mcpManager := mcp.NewManager()
defaultAgent := al.registry.GetDefaultAgent()
workspacePath := al.cfg.WorkspacePath()
if defaultAgent != nil && defaultAgent.Workspace != "" {
workspacePath = defaultAgent.Workspace
}
if err := mcpManager.LoadFromMCPConfig(ctx, al.cfg.Tools.MCP, workspacePath); err != nil {
logger.WarnCF("agent", "Failed to load MCP servers, MCP tools will not be available",
map[string]any{
"error": err.Error(),
})
if closeErr := mcpManager.Close(); closeErr != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": closeErr.Error(),
})
}
return
}
// Register MCP tools for all agents
servers := mcpManager.GetServers()
uniqueTools := 0
totalRegistrations := 0
agentIDs := al.registry.ListAgentIDs()
agentCount := len(agentIDs)
for serverName, conn := range servers {
uniqueTools += len(conn.Tools)
for _, tool := range conn.Tools {
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
mcpTool := tools.NewMCPTool(mcpManager, serverName, tool)
if al.cfg.Tools.MCP.Discovery.Enabled {
agent.Tools.RegisterHidden(mcpTool)
} else {
agent.Tools.Register(mcpTool)
}
totalRegistrations++
logger.DebugCF("agent", "Registered MCP tool",
map[string]any{
"agent_id": agentID,
"server": serverName,
"tool": tool.Name,
"name": mcpTool.Name(),
})
}
}
}
logger.InfoCF("agent", "MCP tools registered successfully",
map[string]any{
"server_count": len(servers),
"unique_tools": uniqueTools,
"total_registrations": totalRegistrations,
"agent_count": agentCount,
})
// Initializes Discovery Tools only if enabled by configuration
if al.cfg.Tools.MCP.Enabled && al.cfg.Tools.MCP.Discovery.Enabled {
useBM25 := al.cfg.Tools.MCP.Discovery.UseBM25
useRegex := al.cfg.Tools.MCP.Discovery.UseRegex
// Fail fast: If discovery is enabled but no search method is turned on
if !useBM25 && !useRegex {
al.mcp.setInitErr(fmt.Errorf(
"tool discovery is enabled but neither 'use_bm25' nor 'use_regex' is set to true in the configuration",
))
if closeErr := mcpManager.Close(); closeErr != nil {
logger.ErrorCF("agent", "Failed to close MCP manager",
map[string]any{
"error": closeErr.Error(),
})
}
return
}
ttl := al.cfg.Tools.MCP.Discovery.TTL
if ttl <= 0 {
ttl = 5 // Default value
}
maxSearchResults := al.cfg.Tools.MCP.Discovery.MaxSearchResults
if maxSearchResults <= 0 {
maxSearchResults = 5 // Default value
}
logger.InfoCF("agent", "Initializing tool discovery", map[string]any{
"bm25": useBM25, "regex": useRegex, "ttl": ttl, "max_results": maxSearchResults,
})
for _, agentID := range agentIDs {
agent, ok := al.registry.GetAgent(agentID)
if !ok {
continue
}
if useRegex {
agent.Tools.Register(tools.NewRegexSearchTool(agent.Tools, ttl, maxSearchResults))
}
if useBM25 {
agent.Tools.Register(tools.NewBM25SearchTool(agent.Tools, ttl, maxSearchResults))
}
}
}
al.mcp.setManager(mcpManager)
})
return al.mcp.getInitErr()
}

340
pkg/agent/loop_orch.go Normal file
View file

@ -0,0 +1,340 @@
package agent
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/orch"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session"
)
func (al *AgentLoop) reporter() orch.AgentReporter {
if al.orchReporter == nil {
return orch.Noop
}
return al.orchReporter
}
// SetOrchReporter wires a Broadcaster as the active reporter.
// Called from cmd_gateway.go when --orchestration is set.
// --orchestration なし → 呼ばれない → reporter() は Noop を返す。
func (al *AgentLoop) SetOrchReporter(b *orch.Broadcaster) {
al.orchBroadcaster = b
al.orchReporter = b
}
// GetOrchBroadcaster returns the concrete Broadcaster for miniapp wiring.
// Returns nil when orchestration is disabled.
func (al *AgentLoop) GetOrchBroadcaster() *orch.Broadcaster {
return al.orchBroadcaster
}
func (al *AgentLoop) notifyStateChange() {
al.promptDirty.Store(true)
if al.OnStateChange != nil {
al.OnStateChange()
}
}
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
if msg.Channel != "system" {
return "", fmt.Errorf("processSystemMessage called with non-system message channel: %s", msg.Channel)
}
logger.InfoCF("agent", "Processing system message",
map[string]any{
"sender_id": msg.SenderID,
"chat_id": msg.ChatID,
})
// Parse origin channel from chat_id (format: "channel:chat_id")
var originChannel, originChatID string
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
originChannel = msg.ChatID[:idx]
originChatID = msg.ChatID[idx+1:]
} else {
originChannel = "cli"
originChatID = msg.ChatID
}
// Extract subagent result from message content
// Format: "Task 'label' completed.\n\nResult:\n<actual content>"
content := msg.Content
if idx := strings.Index(content, "Result:\n"); idx >= 0 {
content = content[idx+8:] // Extract just the result part
}
// Skip internal channels - only log, don't send to user
if constants.IsInternalChannel(originChannel) {
logger.InfoCF("agent", "Subagent completed (internal channel)",
map[string]any{
"sender_id": msg.SenderID,
"content_len": len(content),
"channel": originChannel,
})
return "", nil
}
// Inject subagent result into session history without running a full LLM loop.
// The conductor will see the result on its next turn. This avoids:
// - Flooding the chat with a response for every subagent completion
// - Consuming the Telegram "Thinking..." placeholder
// - Wasting LLM tokens on processing each result individually
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", fmt.Errorf("no default agent for system message")
}
sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
historyMsg := fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content)
// Write as TurnReport to the store for DAG tracking, with legacy fallback.
subagentSessionKey := routing.BuildSubagentSessionKey(extractTaskID(msg.SenderID))
store := agent.Sessions.Store()
reportTurn := &session.Turn{
Kind: session.TurnReport,
OriginKey: subagentSessionKey,
Author: msg.SenderID,
Messages: []providers.Message{{Role: "user", Content: historyMsg}},
}
if err := store.Append(sessionKey, reportTurn); err != nil {
logger.ErrorCF("agent", "Failed to record report turn, falling back to legacy",
map[string]any{"error": err.Error()})
agent.Sessions.AddMessage(sessionKey, "user", historyMsg)
agent.Sessions.MarkDirty(sessionKey)
} else {
// Update in-memory cache so conductor sees the message on next turn.
agent.Sessions.AddFullMessage(sessionKey, providers.Message{Role: "user", Content: historyMsg})
agent.Sessions.AdvanceStored(sessionKey, 1)
}
// Send a brief notification (SkipPlaceholder to avoid corrupting status messages)
label := msg.SenderID
if idx := strings.LastIndex(label, ":"); idx >= 0 {
label = label[idx+1:]
}
notification := formatSubagentCompletion(label, msg.Metadata)
subagentThreadID := 0
if al.cfg != nil {
subagentThreadID = al.cfg.Channels.Telegram.SubagentThreadID
}
notifyChatID := al.withTelegramThread(originChannel, originChatID, subagentThreadID)
_ = al.bus.PublishOutbound(ctx, bus.OutboundMessage{
Channel: originChannel,
ChatID: notifyChatID,
Content: notification,
SkipPlaceholder: true,
})
logger.InfoCF("agent", "Subagent result injected into session history",
map[string]any{
"sender_id": msg.SenderID,
"session_key": sessionKey,
"content_len": len(content),
})
return "", nil
}
// extractTaskID extracts the task ID from a sender ID like "subagent:subagent-1".
func extractTaskID(senderID string) string {
if idx := strings.LastIndex(senderID, ":"); idx >= 0 {
return senderID[idx+1:]
}
return senderID
}
// formatSubagentCompletion builds the user-facing notification for a completed subagent.
// If metadata contains duration_ms and tool_calls it produces e.g.:
//
// "📋 scout-1 completed (3.2s, 5 tool calls)."
//
// Without metadata it falls back to the plain "📋 scout-1 completed." format.
func formatSubagentCompletion(label string, metadata map[string]string) string {
if len(metadata) == 0 {
return fmt.Sprintf("📋 %s completed.", label)
}
durationMs, _ := strconv.ParseInt(metadata["duration_ms"], 10, 64)
toolCalls, _ := strconv.Atoi(metadata["tool_calls"])
if durationMs <= 0 && toolCalls <= 0 {
return fmt.Sprintf("📋 %s completed.", label)
}
parts := make([]string, 0, 2)
if durationMs > 0 {
parts = append(parts, formatDurationMs(durationMs))
}
if toolCalls > 0 {
if toolCalls == 1 {
parts = append(parts, "1 tool call")
} else {
parts = append(parts, fmt.Sprintf("%d tool calls", toolCalls))
}
}
return fmt.Sprintf("📋 %s completed (%s).", label, strings.Join(parts, ", "))
}
// formatDurationMs converts milliseconds to a human-readable duration string.
// Examples: 800 → "0.8s", 1200 → "1.2s", 65000 → "1m5s", 3661000 → "61m1s".
func formatDurationMs(ms int64) string {
if ms < 1000 {
return fmt.Sprintf("%dms", ms)
}
totalSec := ms / 1000
if totalSec < 60 {
tenths := (ms % 1000) / 100
return fmt.Sprintf("%d.%ds", totalSec, tenths)
}
mins := totalSec / 60
sec := totalSec % 60
if sec == 0 {
return fmt.Sprintf("%dm", mins)
}
return fmt.Sprintf("%dm%ds", mins, sec)
}
// buildOrchReminder returns a reminder to use spawn/subagent during plan execution.
// Fires on first iteration and every 3rd iteration to reinforce delegation behavior.
func buildOrchReminder(iteration int) (providers.Message, bool) {
if iteration != 1 && iteration%3 != 0 {
return providers.Message{}, false
}
content := `[System] ORCHESTRATION mode active. You MUST delegate plan steps to subagents.
Use spawn (non-blocking, returns immediately) or subagent (blocking, waits for result).
Do NOT implement steps inline unless they are a single trivial tool call.
To delegate, call the tool with JSON arguments:
Tool: spawn Arguments: {"task": "...", "preset": "scout", "label": "..."}
Tool: subagent Arguments: {"task": "...", "label": "..."}
Spawn multiple independent steps in parallel for maximum throughput.`
return providers.Message{Role: "user", Content: content}, true
}
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
if msg.Peer.Kind == "" {
return nil
}
peerID := msg.Peer.ID
if peerID == "" {
if msg.Peer.Kind == "direct" {
peerID = msg.SenderID
} else {
peerID = msg.ChatID
}
}
return &routing.RoutePeer{Kind: msg.Peer.Kind, ID: peerID}
}
// extractParentPeer extracts the parent peer (reply-to) from inbound message metadata.
func extractParentPeer(msg bus.InboundMessage) *routing.RoutePeer {
parentKind := msg.Metadata["parent_peer_kind"]
parentID := msg.Metadata["parent_peer_id"]
if parentKind == "" || parentID == "" {
return nil
}
return &routing.RoutePeer{Kind: parentKind, ID: parentID}
}

674
pkg/agent/loop_plan.go Normal file
View file

@ -0,0 +1,674 @@
package agent
import (
"errors"
"fmt"
"path/filepath"
"strconv"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/git"
"github.com/sipeed/picoclaw/pkg/orch"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
// interviewRejectMessage is the fixed rejection text injected when tool calls
// are blocked during the interview phase. It is deliberately short to avoid
// wasting tokens, and ends with a purpose reminder to steer the LLM back.
const interviewRejectMessage = "[System] Tool call rejected. " +
"You are in interview mode — ask the user questions and update MEMORY.md. " +
"Do not execute, edit, or write project files."
// buildPlanReminder returns a reminder message for plan pre-execution states
// (interviewing / review) to keep the AI focused on the interview workflow
// during tool-call iterations.
func buildPlanReminder(planStatus string) (providers.Message, bool) {
var content string
switch planStatus {
case "interviewing":
content = "[System] You are interviewing the user to build a plan. " +
"Ask clarifying questions and save findings to ## Context in memory/MEMORY.md using edit_file. " +
"When you have enough information, write ## Phase sections with `- [ ]` checkbox steps, and ## Commands section. " +
"Then change > Status: to review. Do NOT set it to executing."
case "review":
content = "[System] The plan is under review. " +
"Wait for the user to approve or request changes. Do not proceed with execution."
default:
return providers.Message{}, false
}
return providers.Message{Role: "user", Content: content}, true
}
func (al *AgentLoop) handlePlanCommand(args []string, sessionKey string) (string, bool) {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "No agent configured.", true
}
if len(args) == 0 {
// /plan — show current plan
return agent.ContextBuilder.FormatPlanDisplay(), true
}
sub := args[0]
switch sub {
case "clear":
if agent.ContextBuilder.ReadMemory() == "" {
return "No active plan to clear.", true
}
// Deactivate worktree on plan clear
if sessionKey != "" {
agent.DeactivateWorktree(sessionKey, "", true)
}
if err := agent.ContextBuilder.ClearMemory(); err != nil {
return fmt.Sprintf("Error clearing plan: %v", err), true
}
return "Plan cleared.", true
case "done":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan.", true
}
if len(args) < 2 {
return "Usage: /plan done <step number>", true
}
stepNum, err := strconv.Atoi(args[1])
if err != nil || stepNum < 1 {
return "Step number must be a positive integer.", true
}
phase := agent.ContextBuilder.GetCurrentPhase()
if err := agent.ContextBuilder.MarkStep(phase, stepNum); err != nil {
return fmt.Sprintf("Error: %v", err), true
}
return fmt.Sprintf("Marked step %d in phase %d as done.", stepNum, phase), true
case "add":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan.", true
}
if len(args) < 2 {
return "Usage: /plan add <step description>", true
}
desc := strings.Join(args[1:], " ")
phase := agent.ContextBuilder.GetCurrentPhase()
if err := agent.ContextBuilder.AddStep(phase, desc); err != nil {
return fmt.Sprintf("Error: %v", err), true
}
return fmt.Sprintf("Added step to phase %d: %s", phase, desc), true
case "start":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan.", true
}
status := agent.ContextBuilder.GetPlanStatus()
if status == "executing" {
return "Plan is already executing.", true
}
if status != "interviewing" && status != "review" {
return fmt.Sprintf("Cannot start from status %q.", status), true
}
if agent.ContextBuilder.GetTotalPhases() == 0 {
return "Cannot start: no phases defined yet. Complete the interview first.", true
}
if err := agent.ContextBuilder.SetPlanStatus("executing"); err != nil {
return fmt.Sprintf("Error: %v", err), true
}
al.reporter().ReportStateChange(sessionKey, orch.AgentStatePlanExecuting, "")
al.planStartPending = true
clearHistory := len(args) > 1 && args[1] == "clear"
al.planClearHistory = clearHistory
if clearHistory {
return "Plan approved. Executing with clean history.", true
}
return "Plan approved. Executing.", true
case "next":
if !agent.ContextBuilder.HasActivePlan() {
return "No active plan.", true
}
if err := agent.ContextBuilder.AdvancePhase(); err != nil {
return fmt.Sprintf("Error: %v", err), true
}
phase := agent.ContextBuilder.GetCurrentPhase()
return fmt.Sprintf("Advanced to phase %d.", phase), true
case "worktrees":
return al.handlePlanWorktreesCommand(agent, args[1:]), true
default:
// /plan <task description> — start new plan
// Block if a plan is already active (fast-path error).
if agent.ContextBuilder.HasActivePlan() {
return "A plan is already active. Use /plan clear first.", true
}
// Not handled here — let the message flow to the LLM queue.
// expandPlanCommand will write the seed and rewrite the content.
return "", false
}
}
func (al *AgentLoop) handlePlanWorktreesCommand(agent *AgentInstance, args []string) string {
repoRoot := git.FindRepoRoot(agent.Workspace)
if repoRoot == "" {
return "Workspace is not a git repository."
}
worktreesDir := filepath.Join(agent.Workspace, ".worktrees")
sub := "list"
if len(args) > 0 {
sub = strings.ToLower(strings.TrimSpace(args[0]))
}
switch sub {
case "", "list":
items, err := git.ListManagedWorktrees(repoRoot, worktreesDir)
if err != nil {
return fmt.Sprintf("Error listing worktrees: %v", err)
}
if len(items) == 0 {
return "No active worktrees in workspace/.worktrees."
}
var sb strings.Builder
sb.WriteString("Active worktrees\n\n")
for _, wt := range items {
status := "clean"
if wt.HasUncommitted {
status = "dirty"
}
last := "(no commits)"
if wt.LastCommitHash != "" {
if wt.LastCommitAge != "" {
last = fmt.Sprintf("%s %s (%s)", wt.LastCommitHash, wt.LastCommitSubject, wt.LastCommitAge)
} else {
last = fmt.Sprintf("%s %s", wt.LastCommitHash, wt.LastCommitSubject)
}
}
fmt.Fprintf(&sb, "- %s\n branch: %s\n status: %s\n last: %s\n", wt.Name, wt.Branch, status, last)
}
sb.WriteString("\nCommands:\n")
sb.WriteString("/plan worktrees inspect <name>\n")
sb.WriteString("/plan worktrees merge <name>\n")
sb.WriteString("/plan worktrees dispose <name> [force]")
return sb.String()
case "inspect":
if len(args) < 2 {
return "Usage: /plan worktrees inspect <name>"
}
name := args[1]
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name)
if err != nil {
if errors.Is(err, git.ErrInvalidWorktreeName) {
return "Invalid worktree name."
}
if errors.Is(err, git.ErrWorktreeNotFound) {
return fmt.Sprintf("Worktree %q not found.", name)
}
return fmt.Sprintf("Error inspecting worktree %q: %v", name, err)
}
statusOut, _ := git.WorktreeStatusShort(wt.Path)
diffOut, _ := git.WorktreeDiffStat(wt.Path)
logOut, _ := git.WorktreeRecentLog(wt.Path, 10)
if statusOut == "" {
statusOut = "(clean)"
}
var sb strings.Builder
fmt.Fprintf(&sb, "Worktree: %s\nBranch: %s\nDirty: %t\n", wt.Name, wt.Branch, wt.HasUncommitted)
if wt.LastCommitHash != "" {
fmt.Fprintf(&sb, "Last commit: %s %s", wt.LastCommitHash, wt.LastCommitSubject)
if wt.LastCommitAge != "" {
fmt.Fprintf(&sb, " (%s)", wt.LastCommitAge)
}
sb.WriteString("\n")
}
sb.WriteString("\nStatus:\n```\n")
sb.WriteString(statusOut)
sb.WriteString("\n```\n")
if diffOut != "" {
sb.WriteString("\nDiff (stat):\n```\n")
sb.WriteString(diffOut)
sb.WriteString("\n```\n")
}
if logOut != "" {
sb.WriteString("\nRecent commits:\n```\n")
sb.WriteString(logOut)
sb.WriteString("\n```")
}
return sb.String()
case "merge":
if len(args) < 2 {
return "Usage: /plan worktrees merge <name>"
}
name := args[1]
res, base, err := git.MergeManagedWorktree(repoRoot, worktreesDir, name, "")
if err != nil {
if errors.Is(err, git.ErrInvalidWorktreeName) {
return "Invalid worktree name."
}
if errors.Is(err, git.ErrWorktreeNotFound) {
return fmt.Sprintf("Worktree %q not found.", name)
}
return fmt.Sprintf("Error merging worktree %q: %v", name, err)
}
if res.Conflict {
return fmt.Sprintf("Merge conflict while merging `%s` into `%s`. Merge was aborted.", res.Branch, base)
}
if res.Merged {
return fmt.Sprintf("Merged `%s` into `%s`.", res.Branch, base)
}
return fmt.Sprintf("No merge was performed for `%s`.", name)
case "dispose":
if len(args) < 2 {
return "Usage: /plan worktrees dispose <name> [force]"
}
name := args[1]
force := len(args) > 2 && strings.EqualFold(args[2], "force")
wt, err := git.GetManagedWorktree(repoRoot, worktreesDir, name)
if err != nil {
if errors.Is(err, git.ErrInvalidWorktreeName) {
return "Invalid worktree name."
}
if errors.Is(err, git.ErrWorktreeNotFound) {
return fmt.Sprintf("Worktree %q not found.", name)
}
return fmt.Sprintf("Error disposing worktree %q: %v", name, err)
}
if wt.HasUncommitted && !force {
return fmt.Sprintf(
"Worktree `%s` has uncommitted changes. Re-run with `/plan worktrees dispose %s force` to confirm.",
name,
name,
)
}
res, err := git.DisposeManagedWorktree(repoRoot, worktreesDir, name, "")
if err != nil {
return fmt.Sprintf("Error disposing worktree %q: %v", name, err)
}
parts := []string{fmt.Sprintf("Disposed worktree `%s` (branch `%s`).", name, res.Branch)}
if res.AutoCommitted {
parts = append(parts, "Uncommitted changes were auto-committed.")
}
if res.CommitsAhead > 0 {
parts = append(parts, fmt.Sprintf("Branch has %d unique commit(s); branch was kept.", res.CommitsAhead))
}
if res.BranchDeleted {
parts = append(parts, "Branch was deleted (no unique commits).")
}
return strings.Join(parts, " ")
}
return "Usage: /plan worktrees [list|inspect <name>|merge <name>|dispose <name> [force]]"
}
// isPlanPreExecution returns true if the plan is in a pre-execution state
// (interviewing or review) where tool restrictions and iteration caps apply.
func isPlanPreExecution(status string) bool {
return status == "interviewing" || status == "review"
}
// interviewAllowedTools is the single source of truth for tool names that may
// be sent to the LLM (and subsequently invoked) during the interview phase.
// filterInterviewTools uses this to strip tool *definitions* before the LLM call,
// while isToolAllowedDuringInterview adds argument-level checks as a second gate.
var interviewAllowedTools = map[string]bool{
"readfile": true,
"listdir": true,
"websearch": true,
"webfetch": true,
"message": true,
"editfile": true,
"appendfile": true,
"writefile": true,
"exec": true,
"logs": true,
}
// filterInterviewTools removes tool definitions that are not in the
// interviewAllowedTools whitelist, reducing token usage and preventing the
// LLM from attempting disallowed tool calls during the interview phase.
func filterInterviewTools(defs []providers.ToolDefinition) []providers.ToolDefinition {
filtered := make([]providers.ToolDefinition, 0, len(defs))
for _, d := range defs {
if interviewAllowedTools[tools.NormalizeToolName(d.Function.Name)] {
filtered = append(filtered, d)
}
}
return filtered
}
// isToolAllowedDuringInterview checks whether a tool call is permitted while the
// plan is in a pre-execution state. Uses the shared interviewAllowedTools map for
// name-level gating, then applies argument-level constraints for write-type tools
// (MEMORY.md only) and exec (read-only commands only).
func isToolAllowedDuringInterview(toolName string, args map[string]any) bool {
norm := tools.NormalizeToolName(toolName)
if !interviewAllowedTools[norm] {
return false
}
// Argument-level constraints
switch norm {
case "editfile", "appendfile", "writefile":
path, _ := args["path"].(string)
return strings.HasSuffix(path, "MEMORY.md")
case "exec":
cmd, _ := args["command"].(string)
return isReadOnlyCommand(cmd)
}
return true
}
// isReadOnlyCommand returns true when cmd is a safe, read-only shell command
// that an LLM may run during the interview phase.
func isReadOnlyCommand(cmd string) bool {
cmd = strings.TrimSpace(cmd)
if cmd == "" {
return false
}
// Reject write operators anywhere in the command
for _, op := range []string{">", ">>", "| tee "} {
if strings.Contains(cmd, op) {
return false
}
}
// Reject path traversal (defense in depth; ExecTool.guardCommand also enforces workspace restriction)
if strings.Contains(cmd, "..") {
return false
}
// Block absolute paths in arguments (allow "cd /path && cmd" which is stripped later)
for _, field := range strings.Fields(cmd) {
if strings.HasPrefix(field, "/") && !strings.HasPrefix(cmd, "cd ") {
return false
}
}
// Strip "cd /path &&" prefix (LLM habit)
if strings.HasPrefix(cmd, "cd ") {
if idx := strings.Index(cmd, "&&"); idx >= 0 {
cmd = strings.TrimSpace(cmd[idx+2:])
}
}
fields := strings.Fields(cmd)
if len(fields) == 0 {
return false
}
first := filepath.Base(fields[0])
switch first {
case "find", "ls", "cat", "head", "tail", "grep", "rg",
"tree", "wc", "file", "which", "pwd",
"uname", "df", "du", "stat", "realpath", "dirname",
"basename", "date":
return true
}
return false
}
// isWriteTool returns true if the tool can modify files.
func isWriteTool(name string) bool {
switch tools.NormalizeToolName(name) {
case "writefile", "editfile", "appendfile", "exec":
return true
}
return false
}
// expandPlanCommand detects "/plan <task>" (new plan start) and:
// - writes the interview seed to MEMORY.md
// - rewrites the message content for the LLM
// - returns a compact form for session history
//
// This follows the same pattern as expandSkillCommand: the message is
// rewritten before reaching the LLM, so the AI sees the task description
// while the system prompt contains the interview guide.
func (al *AgentLoop) expandPlanCommand(msg bus.InboundMessage) (expanded string, compact string, ok bool) {
content := strings.TrimSpace(msg.Content)
if !strings.HasPrefix(content, "/plan ") {
return "", "", false
}
task := strings.TrimSpace(content[6:]) // len("/plan ") == 6
if task == "" {
return "", "", false
}
// Known subcommands are handled by handlePlanCommand (fast path).
firstWord := strings.Fields(task)[0]
switch firstWord {
case "clear", "done", "add", "start", "next", "worktrees":
return "", "", false
}
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "", "", false
}
// If a plan is already active, don't expand — handleCommand will
// catch it and return the error on the fast path.
if agent.ContextBuilder.HasActivePlan() {
return "", "", false
}
// Write the interview seed
seed := BuildInterviewSeed(task, agent.Workspace)
if err := agent.ContextBuilder.WriteMemory(seed); err != nil {
return "", "", false
}
al.notifyStateChange()
// Expanded: the task description goes to LLM.
// The system prompt already contains the interview guide.
expanded = task
compact = fmt.Sprintf("[Plan: %s]", utils.Truncate(task, 80))
return expanded, compact, true
}

393
pkg/agent/loop_session.go Normal file
View file

@ -0,0 +1,393 @@
package agent
import (
"context"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
)
// sessionSemaphore is a per-session mutex using a buffered channel.
type sessionSemaphore struct {
ch chan struct{}
}
func newSessionSemaphore() *sessionSemaphore {
s := &sessionSemaphore{ch: make(chan struct{}, 1)}
s.ch <- struct{}{} // initially unlocked
return s
}
func (al *AgentLoop) gcLoop() {
ticker := time.NewTicker(30 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
al.gcSessionLocks()
case <-al.done:
return
}
}
}
// gcSessionLocks removes unlocked (idle) sessionSemaphore entries from the map.
func (al *AgentLoop) gcSessionLocks() {
al.sessionLocks.Range(func(key, val any) bool {
sem := val.(*sessionSemaphore)
select {
case <-sem.ch:
// Was unlocked — safe to remove
al.sessionLocks.Delete(key)
default:
// Currently locked — in use, keep
}
return true
})
}
func (al *AgentLoop) acquireSessionLock(ctx context.Context, sessionKey string) bool {
val, _ := al.sessionLocks.LoadOrStore(sessionKey, newSessionSemaphore())
sem := val.(*sessionSemaphore)
select {
case <-sem.ch:
return true
case <-ctx.Done():
return false
}
}
// releaseSessionLock releases the per-session semaphore.
func (al *AgentLoop) releaseSessionLock(sessionKey string) {
if val, ok := al.sessionLocks.Load(sessionKey); ok {
sem := val.(*sessionSemaphore)
sem.ch <- struct{}{}
}
}
func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory)
threshold := agent.ContextWindow * 75 / 100
if len(newHistory) > 20 || tokenEstimate > threshold {
summarizeKey := agent.ID + ":" + sessionKey
if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
defer al.summarizing.Delete(summarizeKey)
logger.InfoCF("agent", "Memory threshold reached, optimizing conversation history",
map[string]any{
"session_key": sessionKey,
"history_len": len(newHistory),
"token_estimate": tokenEstimate,
})
al.summarizeSession(agent, sessionKey)
}()
}
}
}
// forceCompression aggressively reduces context when the limit is hit.
// It drops the oldest 50% of messages (keeping system prompt and last user message).
func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
history := agent.Sessions.GetHistory(sessionKey)
if len(history) <= 4 {
return
}
// Keep system prompt (usually [0]) and the very last message (user's trigger)
// We want to drop the oldest half of the *conversation*
// Assuming [0] is system, [1:] is conversation
conversation := history[1 : len(history)-1]
if len(conversation) == 0 {
return
}
// Helper to find the mid-point of the conversation
mid := len(conversation) / 2
// New history structure:
// 1. System Prompt (with compression note appended)
// 2. Second half of conversation
// 3. Last message
droppedCount := mid
keptConversation := conversation[mid:]
newHistory := make([]providers.Message, 0, 1+len(keptConversation)+1)
// Append compression note to the original system prompt instead of adding a new system message
// This avoids having two consecutive system messages which some APIs (like Zhipu) reject
compressionNote := fmt.Sprintf(
"\n\n[System Note: Emergency compression dropped %d oldest messages due to context limit]",
droppedCount,
)
enhancedSystemPrompt := history[0]
enhancedSystemPrompt.Content = enhancedSystemPrompt.Content + compressionNote
newHistory = append(newHistory, enhancedSystemPrompt)
newHistory = append(newHistory, keptConversation...)
newHistory = append(newHistory, history[len(history)-1]) // Last message
// Update session
agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey)
logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
})
}
func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
history := agent.Sessions.GetHistory(sessionKey)
summary := agent.Sessions.GetSummary(sessionKey)
// Keep last 4 messages for continuity
if len(history) <= 4 {
return
}
toSummarize := history[:len(history)-4]
// Oversized Message Guard
maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
for _, m := range toSummarize {
if m.Role != "user" && m.Role != "assistant" {
continue
}
msgTokens := len(m.Content) / 2
if msgTokens > maxMessageTokens {
omitted = true
continue
}
validMessages = append(validMessages, m)
}
if len(validMessages) == 0 {
return
}
// Multi-Part Summarization
var finalSummary string
if len(validMessages) > 10 {
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
mergePrompt := fmt.Sprintf(
"Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s",
s1,
s2,
)
resp, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: mergePrompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
"prompt_cache_key": agent.ID,
},
)
if err == nil {
finalSummary = resp.Content
} else {
finalSummary = s1 + " " + s2
}
} else {
finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
}
if omitted && finalSummary != "" {
finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]"
}
if finalSummary != "" {
if err := agent.Sessions.CompactOldTurns(sessionKey, 4, finalSummary); err != nil {
logger.ErrorCF("agent", "CompactOldTurns failed, falling back",
map[string]any{"error": err.Error()})
agent.Sessions.SetSummary(sessionKey, finalSummary)
agent.Sessions.TruncateHistory(sessionKey, 4)
agent.Sessions.Save(sessionKey)
}
}
}
// summarizeBatch summarizes a batch of messages.
func (al *AgentLoop) summarizeBatch(
ctx context.Context,
agent *AgentInstance,
batch []providers.Message,
existingSummary string,
) (string, error) {
var sb strings.Builder
sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n")
if agent.ContextBuilder.HasActivePlan() {
sb.WriteString("Note: Active plan in MEMORY.md. Preserve plan progress references.\n")
}
if existingSummary != "" {
sb.WriteString("Existing context: ")
sb.WriteString(existingSummary)
sb.WriteString("\n")
}
sb.WriteString("\nCONVERSATION:\n")
for _, m := range batch {
fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
}
prompt := sb.String()
response, err := agent.Provider.Chat(
ctx,
[]providers.Message{{Role: "user", Content: prompt}},
nil,
agent.Model,
map[string]any{
"max_tokens": 1024,
"temperature": 0.3,
"prompt_cache_key": agent.ID,
},
)
if err != nil {
return "", err
}
return response.Content, nil
}
// estimateTokens estimates the number of tokens in a message list.
// Uses a safe heuristic of 2.5 characters per token to account for CJK and other
// overheads better than the previous 3 chars/token.
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
totalChars := 0
for _, m := range messages {
totalChars += utf8.RuneCountInString(m.Content)
}
// 2.5 chars per token = totalChars * 2 / 5
return totalChars * 2 / 5
}

317
pkg/agent/loop_streaming.go Normal file
View file

@ -0,0 +1,317 @@
package agent
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/providers/protocoltypes"
"github.com/sipeed/picoclaw/pkg/utils"
)
func (al *AgentLoop) handleReasoning(ctx context.Context, reasoningContent, channelName, channelID string) {
if reasoningContent == "" || channelName == "" || channelID == "" {
return
}
// Check context cancellation before attempting to publish,
// since PublishOutbound's select may race between send and ctx.Done().
if ctx.Err() != nil {
return
}
// Use a short timeout so the goroutine does not block indefinitely when
// the outbound bus is full. Reasoning output is best-effort; dropping it
// is acceptable to avoid goroutine accumulation.
pubCtx, pubCancel := context.WithTimeout(ctx, 5*time.Second)
defer pubCancel()
if err := al.bus.PublishOutbound(pubCtx, bus.OutboundMessage{
Channel: channelName,
ChatID: channelID,
Content: reasoningContent,
}); err != nil {
// Treat context.DeadlineExceeded / context.Canceled as expected
// (bus full under load, or parent canceled). Check the error
// itself rather than ctx.Err(), because pubCtx may time out
// (5 s) while the parent ctx is still active.
// Also treat ErrBusClosed as expected — it occurs during normal
// shutdown when the bus is closed before all goroutines finish.
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) ||
errors.Is(err, bus.ErrBusClosed) {
logger.DebugCF("agent", "Reasoning publish skipped (timeout/cancel)", map[string]any{
"channel": channelName,
"error": err.Error(),
})
} else {
logger.WarnCF("agent", "Failed to publish reasoning (best-effort)", map[string]any{
"channel": channelName,
"error": err.Error(),
})
}
}
}
// streamingReasoningLines is the number of lines reserved for reasoning
// in the streaming display. The remaining lines go to content.
const streamingReasoningLines = 6
// buildStreamingDisplay builds a fixed-height status bubble for streaming.
//
// Layout when reasoning is active (reasoning only or both):
//
// 🧠 Thinking...
// ━━━━━━━━━━
// <reasoning tail — streamingReasoningLines lines>
// ━━━━━━━━━━
// <content tail — remaining lines> (or blank if content is empty)
// █
//
// Layout when no reasoning (content only):
//
// <content tail — streamingDisplayLines lines>
// █
func buildStreamingDisplay(content, reasoning string) string {
if reasoning == "" {
// No reasoning — full window for content.
return utils.TailPad(content, streamingDisplayLines, maxEntryLineWidth) + " \u2589"
}
var sb strings.Builder
// Header
if content == "" {
sb.WriteString("\U0001f9e0 Thinking...\n")
} else {
sb.WriteString("\U0001f9e0 Thought, now responding...\n")
}
sb.WriteString(statusSeparator)
// Reasoning window
headerLines := 2 // header + separator
footerLines := 1 // separator before content
contentLines := streamingDisplayLines - headerLines - footerLines - streamingReasoningLines
if contentLines < 3 {
contentLines = 3
}
rLines := streamingDisplayLines - headerLines - footerLines - contentLines
sb.WriteString(utils.TailPad(reasoning, rLines, maxEntryLineWidth))
sb.WriteByte('\n')
sb.WriteString(statusSeparator)
// Content window (may be blank padding if content hasn't started)
sb.WriteString(utils.TailPad(content, contentLines, maxEntryLineWidth))
sb.WriteString(" \u2589")
return sb.String()
}
// consumeStreamWithRepetitionDetection reads StreamEvents from ch, accumulates
// content and tool calls, and runs repetition detection every checkInterval runes.
// If repetition is detected, cancelFn is called to abort the HTTP request and
// the function returns the partial response with detected=true.
func consumeStreamWithRepetitionDetection(
ch <-chan protocoltypes.StreamEvent,
cancelFn context.CancelFunc,
checkInterval int,
onChunk func(content, reasoning string),
) (*providers.LLMResponse, bool, error) {
var content strings.Builder
var reasoning strings.Builder
var toolCalls []streamToolCallAcc
var finishReason string
var usage *providers.UsageInfo
runesSinceLastCheck := 0
for ev := range ch {
if ev.Err != nil {
return nil, false, ev.Err
}
updated := false
if ev.ContentDelta != "" {
content.WriteString(ev.ContentDelta)
runesSinceLastCheck += utf8.RuneCountInString(ev.ContentDelta)
updated = true
}
if ev.ReasoningDelta != "" {
reasoning.WriteString(ev.ReasoningDelta)
updated = true
}
if updated && onChunk != nil {
onChunk(content.String(), reasoning.String())
}
if ev.FinishReason != "" {
finishReason = ev.FinishReason
}
if ev.Usage != nil {
usage = ev.Usage
}
for _, tc := range ev.ToolCallDeltas {
for len(toolCalls) <= tc.Index {
toolCalls = append(toolCalls, streamToolCallAcc{})
}
if tc.ID != "" {
toolCalls[tc.Index].id = tc.ID
}
if tc.Name != "" {
toolCalls[tc.Index].name = tc.Name
}
toolCalls[tc.Index].args.WriteString(tc.ArgumentsDelta)
}
// Run repetition detection periodically on accumulated content.
if runesSinceLastCheck >= checkInterval && content.Len() > 2000 {
runesSinceLastCheck = 0
if utils.DetectRepetitionLoop(content.String()) {
cancelFn()
// Drain remaining events so the producer goroutine can exit.
for range ch {
}
resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage)
return resp, true, nil
}
}
}
resp := buildAccumulatedResponse(content.String(), reasoning.String(), toolCalls, finishReason, usage)
return resp, false, nil
}
// streamToolCallAcc accumulates streamed tool call fragments.
type streamToolCallAcc struct {
id string
name string
args strings.Builder
}
// buildAccumulatedResponse constructs an LLMResponse from accumulated stream data.
func buildAccumulatedResponse(
content, reasoning string,
toolCalls []streamToolCallAcc,
finishReason string,
usage *providers.UsageInfo,
) *providers.LLMResponse {
resp := &providers.LLMResponse{
Content: content,
Reasoning: reasoning,
FinishReason: finishReason,
Usage: usage,
}
for _, tc := range toolCalls {
arguments := make(map[string]any)
argStr := tc.args.String()
if argStr != "" {
if err := json.Unmarshal([]byte(argStr), &arguments); err != nil {
arguments["raw"] = argStr
}
}
resp.ToolCalls = append(resp.ToolCalls, providers.ToolCall{
ID: tc.id,
Name: tc.name,
Arguments: arguments,
})
}
return resp
}

701
pkg/agent/loop_task.go Normal file
View file

@ -0,0 +1,701 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"sync"
"time"
"unicode"
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/utils"
)
// activeTask tracks a running agent task for live status and intervention.
type activeTask struct {
Description string
Result string // LLM response summary for completion notification
Iteration int
MaxIter int
StartedAt time.Time
cancel context.CancelFunc
interrupt chan string // buffered 1, for user message injection
toolLog []toolLogEntry
lastError *toolLogEntry // sticky: most recent error, persists across iterations
projectDir string // detected from exec cd target (authoritative)
fileCommonDir string // LCP of file paths relative to workspace (fallback)
streamedChunks bool // true after onChunk fires at least once
messageContent string // last content sent by the message tool (for inclusion in completion)
mu sync.Mutex
}
// toolLogEntry records a single tool call for the live terminal view.
type toolLogEntry struct {
Name string
ArgsSnip string // first ~80 chars of args
Result string // "✓ 4.9s" or "✗ 3.2s"
ErrDetail string // non-empty on error — e.g. "Exit code: exit status 1"
}
// maxToolLogEntries limits the sliding window of tool log entries
// kept in memory and displayed in status messages.
const maxToolLogEntries = 5
// Task reminder constants and helpers.
const (
taskReminderMaxChars = 500
blockerMaxChars = 200
)
func shouldInjectReminder(iteration, interval int) bool {
if interval <= 0 {
return false
}
return iteration > 1 && iteration%interval == 0
}
func buildTaskReminder(userMessage string, lastBlocker string) providers.Message {
truncatedTask := utils.Truncate(userMessage, taskReminderMaxChars)
var content string
if lastBlocker != "" {
truncatedBlocker := utils.Truncate(lastBlocker, blockerMaxChars)
content = fmt.Sprintf(
"[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nLast blocker:\n---\n%s\n---\nFix the blocker if essential, or find an alternative. If all steps are complete, move on.",
truncatedTask,
truncatedBlocker,
)
} else {
content = fmt.Sprintf(
"[TASK REMINDER]\nOriginal task:\n---\n%s\n---\nIf all steps of the original task are complete, move on. Otherwise, continue with the next step.",
truncatedTask,
)
}
return providers.Message{
Role: "user",
Content: content,
}
}
// cdPrefixPattern matches "cd /some/path && " at the start of a shell command.
// Group 1 captures the target directory path.
var cdPrefixPattern = regexp.MustCompile(`^cd\s+(\S+)\s*&&\s*`)
// optFlagPattern matches option flags like --verbose, -v, --timeout=60, -q.
// Only standalone flags are removed; flags whose value is the next positional
// argument (e.g. "-A 20") are kept because removing them would lose context.
var optFlagPattern = regexp.MustCompile(`\s+--?\w[\w-]*(=\S*)?`)
// extractExecProjectDir extracts the basename of an exec cd target.
// Returns "" if the command has no cd prefix.
func extractExecProjectDir(args map[string]any) string {
cmd, _ := args["command"].(string)
if cmd == "" {
return ""
}
m := cdPrefixPattern.FindStringSubmatch(cmd)
if len(m) < 2 {
return ""
}
cdPath := strings.TrimRight(m[1], "/\\")
if idx := strings.LastIndex(cdPath, "/"); idx >= 0 {
return cdPath[idx+1:]
}
if idx := strings.LastIndex(cdPath, "\\"); idx >= 0 {
return cdPath[idx+1:]
}
return cdPath
}
// fileParentRelDir returns the parent directory of a file path, relative to
// workspace. Returns "" if the path is not under workspace or has no parent.
func fileParentRelDir(filePath, workspace string) string {
ws := strings.TrimRight(workspace, "/\\")
if ws == "" {
return ""
}
rest := strings.TrimPrefix(filePath, ws)
if rest == filePath {
return "" // not under workspace
}
rest = strings.TrimLeft(rest, "/\\")
// Remove the filename — keep only the directory part
if idx := strings.LastIndexAny(rest, "/\\"); idx >= 0 {
return rest[:idx]
}
return "" // file is directly under workspace, no meaningful dir
}
// commonDirPrefix computes the longest common directory prefix of two
// slash-separated paths. Returns "" if there is no common component.
func commonDirPrefix(a, b string) string {
partsA := strings.Split(a, "/")
partsB := strings.Split(b, "/")
n := len(partsA)
if len(partsB) < n {
n = len(partsB)
}
common := 0
for i := 0; i < n; i++ {
if partsA[i] != partsB[i] {
break
}
common = i + 1
}
if common == 0 {
return ""
}
return strings.Join(partsA[:common], "/")
}
// displayProjectDir returns the project directory name for status display.
// Prefers the authoritative exec-based projectDir; falls back to the
// basename of the file-based common directory.
func displayProjectDir(task *activeTask) string {
if task.projectDir != "" {
return task.projectDir
}
if task.fileCommonDir != "" {
dir := task.fileCommonDir
if idx := strings.LastIndex(dir, "/"); idx >= 0 {
return dir[idx+1:]
}
return dir
}
return ""
}
// buildArgsSnippet produces a human-friendly snippet for the tool log.
// For exec: extracts the command and strips the leading "cd <workspace> && ".
// For file tools: extracts the path and strips the workspace prefix.
// Falls back to raw JSON truncation.
func buildArgsSnippet(toolName string, args map[string]any, workspace string) string {
switch toolName {
case "exec":
cmd, _ := args["command"].(string)
if cmd == "" {
break
}
cmd = cdPrefixPattern.ReplaceAllString(cmd, "")
cmd = optFlagPattern.ReplaceAllString(cmd, "")
return utils.Truncate(cmd, 80)
case "read_file", "write_file", "edit_file", "append_file", "list_dir":
path, _ := args["path"].(string)
if path == "" {
break
}
if workspace != "" {
path = strings.TrimPrefix(path, workspace)
path = strings.TrimPrefix(path, "/")
}
// Prioritize filename: if path is too long, show "…/filename"
const maxPath = 60
if runes := []rune(path); len(runes) > maxPath {
// Find last slash to extract filename
if lastSlash := strings.LastIndex(path, "/"); lastSlash >= 0 {
filename := path[lastSlash:] // includes "/"
dirBudget := maxPath - len([]rune(filename)) - 1 // 1 for "…"
if dirBudget > 0 {
dir := []rune(path[:lastSlash])
if len(dir) > dirBudget {
dir = dir[:dirBudget]
}
path = string(dir) + "\u2026" + filename
} else {
path = "\u2026" + filename
}
} else {
path = utils.Truncate(path, maxPath)
}
}
return path
}
// Default: raw JSON truncated
argsJSON, _ := json.Marshal(args)
return utils.Truncate(string(argsJSON), 80)
}
// maxEntryLineWidth is the max rune count for a single-line log entry.
// Telegram chat bubbles on mobile are roughly 40-45 chars wide.
const maxEntryLineWidth = 42
// isFileToolEntry returns true if the entry name contains a file-operation tool.
func isFileToolEntry(name string) bool {
for _, t := range []string{"read_file", "write_file", "edit_file", "append_file", "list_dir"} {
if strings.Contains(name, t) {
return true
}
}
return false
}
// formatCompactEntry formats a finished tool log entry as a fixed single line.
// The result marker (✓/✗) is always shown at the end regardless of truncation.
// File tools omit duration (always near-instant); paths truncate from the
// start so the filename is always visible.
func formatCompactEntry(entry toolLogEntry) string {
result := entry.Result
if result == "" {
result = "\u23F3" // ⏳
}
// File tools: strip duration, keep only marker (✓/✗/⏳)
isFile := isFileToolEntry(entry.Name)
if isFile {
if r := []rune(result); len(r) > 0 {
result = string(r[0:1]) // just the symbol
}
}
// Budget for ArgsSnip: total - name - " " - " " - result
nameLen := utf8.RuneCountInString(entry.Name)
resultLen := utf8.RuneCountInString(result)
argsBudget := maxEntryLineWidth - nameLen - 1 - 1 - resultLen
args := entry.ArgsSnip
if args != "" && argsBudget > 3 {
argsRunes := []rune(args)
if len(argsRunes) > argsBudget {
// Paths: truncate from the start, keeping the filename visible
if strings.Contains(args, "/") {
args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:])
} else {
args = string(argsRunes[:argsBudget-1]) + "\u2026"
}
}
var sb strings.Builder
sb.Grow(len(entry.Name) + 1 + len(args) + 1 + len(result))
sb.WriteString(entry.Name)
sb.WriteByte(' ')
sb.WriteString(args)
sb.WriteByte(' ')
sb.WriteString(result)
return sb.String()
}
// No room for args or args empty
var sb strings.Builder
sb.Grow(len(entry.Name) + 1 + len(result))
sb.WriteString(entry.Name)
sb.WriteByte(' ')
sb.WriteString(result)
return sb.String()
}
// formatLatestEntry formats the latest entry command without its result marker.
// Since the result goes on the next line, the full width is available for the command.
func formatLatestEntry(entry toolLogEntry) string {
nameLen := utf8.RuneCountInString(entry.Name)
argsBudget := maxEntryLineWidth - nameLen - 1 // name + space + args (no result)
args := entry.ArgsSnip
if args != "" && argsBudget > 3 {
argsRunes := []rune(args)
if len(argsRunes) > argsBudget {
if strings.Contains(args, "/") {
args = "\u2026" + string(argsRunes[len(argsRunes)-argsBudget+1:])
} else {
args = string(argsRunes[:argsBudget-1]) + "\u2026"
}
}
var sb strings.Builder
sb.Grow(len(entry.Name) + 1 + len(args))
sb.WriteString(entry.Name)
sb.WriteByte(' ')
sb.WriteString(args)
return sb.String()
}
return entry.Name
}
// compressRepeats reduces runs of 3+ identical non-alphanumeric, non-space
// characters to just 2. e.g. "======" → "==", "---" → "--".
func compressRepeats(s string) string {
runes := []rune(s)
if len(runes) < 3 {
return s
}
var sb strings.Builder
sb.Grow(len(s))
i := 0
for i < len(runes) {
r := runes[i]
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !unicode.IsSpace(r) {
j := i + 1
for j < len(runes) && runes[j] == r {
j++
}
if j-i >= 3 {
sb.WriteRune(r)
sb.WriteRune(r)
i = j
continue
}
}
sb.WriteRune(r)
i++
}
return sb.String()
}
// Display layout constants.
const (
displayPastEntries = 4 // number of compact 1-line past entries
displayErrorLines = 5 // content lines inside the error code block
statusSeparator = "\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\n"
streamingDisplayLines = 17 // line count matching buildRichStatus output
)
// buildRichStatus builds a fixed-height terminal-like status display.
//
// Layout (always the same number of lines):
//
// 🔄 Task in progress (N/M) header
// 📁 workspace-path header
// ━━━━━━━━━━ separator
// [N] compact-past-1 ✓ Xs past (1 line each)
// [N] compact-past-2 ✗ Xs past
// [N] compact-past-3 ✓ Xs past
// [N] compact-past-4 ✓ Xs past
// [N] latest-command latest (no result, wider args)
// ⏳ latest result
// reserved
// ``` error fence
// err-line / placeholder error body (5 lines)
// ``` error fence
// ↩️ Reply to intervene footer (background only)
func buildRichStatus(task *activeTask, isBackground bool, workspace string) string {
task.mu.Lock()
defer task.mu.Unlock()
var sb strings.Builder
// --- Header ---
sb.WriteString("\U0001F504 Task in progress (")
sb.WriteString(strconv.Itoa(task.Iteration))
sb.WriteByte('/')
sb.WriteString(strconv.Itoa(task.MaxIter))
sb.WriteString(")\n")
// Project directory: exec cd (authoritative) → file LCP → workspace basename
sb.WriteString("\U0001F4C1 ")
if dir := displayProjectDir(task); dir != "" {
sb.WriteString(dir)
} else if workspace != "" {
project := strings.TrimRight(workspace, "/\\")
if idx := strings.LastIndex(project, "/"); idx >= 0 {
project = project[idx+1:]
} else if idx := strings.LastIndex(project, "\\"); idx >= 0 {
project = project[idx+1:]
}
sb.WriteString(project)
}
sb.WriteByte('\n')
sb.WriteString(statusSeparator)
// --- Task entries (displayPastEntries + 2 lines for latest) ---
entries := task.toolLog
if len(entries) > maxToolLogEntries {
entries = entries[len(entries)-maxToolLogEntries:]
}
var pastEntries []toolLogEntry
var latest *toolLogEntry
if len(entries) > 0 {
latest = &entries[len(entries)-1]
if len(entries) > 1 {
start := len(entries) - 1 - displayPastEntries
if start < 0 {
start = 0
}
pastEntries = entries[start : len(entries)-1]
}
}
// Past entries: exactly displayPastEntries lines (pad if fewer)
for i := 0; i < displayPastEntries; i++ {
if i < len(pastEntries) {
sb.WriteString(formatCompactEntry(pastEntries[i]))
} else {
sb.WriteString("\u2800")
}
sb.WriteByte('\n')
}
// Latest entry: command on one line, result on next
if latest != nil {
sb.WriteString(formatLatestEntry(*latest))
sb.WriteByte('\n')
sb.WriteString(" ")
if latest.Result != "" {
sb.WriteString(latest.Result)
} else {
sb.WriteString("\u23F3")
}
sb.WriteByte('\n')
} else {
sb.WriteString("\u23F3 waiting...\n")
sb.WriteString("\u2800\n")
}
// Reserved (1 line)
sb.WriteString("\u2800\n")
// --- Error region (code fence, no separator) ---
sb.WriteString("```\n")
errEntry := task.lastError
if errEntry != nil {
sb.WriteString("\u274C ")
sb.WriteString(formatCompactEntry(*errEntry))
sb.WriteByte('\n')
var detailLines []string
if errEntry.ErrDetail != "" {
detailLines = strings.Split(errEntry.ErrDetail, "\n")
}
for i := 0; i < displayErrorLines-1; i++ {
if i < len(detailLines) {
line := compressRepeats(detailLines[i])
if runes := []rune(line); len(runes) > maxEntryLineWidth {
line = string(runes[:maxEntryLineWidth-1]) + "\u2026"
}
sb.WriteString(line)
} else {
sb.WriteString("\u2800")
}
sb.WriteByte('\n')
}
} else {
sb.WriteString("\u2714 No errors\n")
for i := 0; i < displayErrorLines-1; i++ {
sb.WriteString("\u2800\n")
}
}
sb.WriteString("```\n")
if isBackground {
sb.WriteString("\u21A9\uFE0F Reply to intervene")
}
return sb.String()
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

728
pkg/agent/memory_ext.go Normal file
View file

@ -0,0 +1,728 @@
package agent
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
)
// Cache types for MemoryStore.
type longTermFileCache struct {
loaded bool
exists bool
modTime time.Time
size int64
content string
}
type parsedPlanStateCache struct {
loaded bool
sourceContent string
state parsedPlanState
}
type parsedPlanState struct {
content string
hasActivePlan bool
status string
currentPhase int
totalPhases int
workDir string
taskName string
phases []PlanPhase
}
// InvalidateCache clears all in-memory caches for MEMORY.md content and parsed plan state.
func (ms *MemoryStore) InvalidateCache() {
ms.cacheMu.Lock()
defer ms.cacheMu.Unlock()
ms.longTermCache = longTermFileCache{}
ms.parsedPlanCache = parsedPlanStateCache{}
}
func (ms *MemoryStore) readLongTermCached() string {
info, err := os.Stat(ms.memoryFile)
if err != nil {
if !os.IsNotExist(err) {
return ""
}
ms.cacheMu.RLock()
cachedMissing := ms.longTermCache.loaded && !ms.longTermCache.exists
ms.cacheMu.RUnlock()
if cachedMissing {
return ""
}
ms.cacheMu.Lock()
ms.longTermCache = longTermFileCache{loaded: true, exists: false}
ms.parsedPlanCache = parsedPlanStateCache{}
ms.cacheMu.Unlock()
return ""
}
modTime := info.ModTime()
size := info.Size()
ms.cacheMu.RLock()
if ms.longTermCache.loaded &&
ms.longTermCache.exists &&
ms.longTermCache.modTime.Equal(modTime) &&
ms.longTermCache.size == size {
content := ms.longTermCache.content
ms.cacheMu.RUnlock()
return content
}
ms.cacheMu.RUnlock()
data, err := os.ReadFile(ms.memoryFile)
if err != nil {
if os.IsNotExist(err) {
ms.cacheMu.Lock()
ms.longTermCache = longTermFileCache{loaded: true, exists: false}
ms.parsedPlanCache = parsedPlanStateCache{}
ms.cacheMu.Unlock()
}
return ""
}
content := string(data)
ms.cacheMu.Lock()
ms.longTermCache = longTermFileCache{
loaded: true,
exists: true,
modTime: modTime,
size: size,
content: content,
}
if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent != content {
ms.parsedPlanCache = parsedPlanStateCache{}
}
ms.cacheMu.Unlock()
return content
}
func (ms *MemoryStore) getParsedPlanState() parsedPlanState {
content := ms.ReadLongTerm()
ms.cacheMu.RLock()
if ms.parsedPlanCache.loaded && ms.parsedPlanCache.sourceContent == content {
state := ms.parsedPlanCache.state
ms.cacheMu.RUnlock()
return state
}
ms.cacheMu.RUnlock()
state := ms.parsePlanState(content)
ms.cacheMu.Lock()
if !ms.parsedPlanCache.loaded || ms.parsedPlanCache.sourceContent != content {
ms.parsedPlanCache = parsedPlanStateCache{
loaded: true,
sourceContent: content,
state: state,
}
} else {
state = ms.parsedPlanCache.state
}
ms.cacheMu.Unlock()
return state
}
func (ms *MemoryStore) parsePlanState(content string) parsedPlanState {
state := parsedPlanState{content: content}
if content == "" || !reActivePlan.MatchString(content) {
return state
}
state.hasActivePlan = true
if m := reStatus.FindStringSubmatch(content); len(m) >= 2 {
state.status = strings.TrimSpace(m[1])
}
if m := rePhase.FindStringSubmatch(content); len(m) >= 2 {
state.currentPhase, _ = strconv.Atoi(m[1])
}
state.totalPhases = maxPhaseNumber(content)
if m := reWorkDir.FindStringSubmatch(content); len(m) >= 2 {
state.workDir = strings.TrimSpace(m[1])
}
if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 {
state.taskName = strings.TrimSpace(m[1])
}
state.phases = ms.getPlanPhasesFrom(content)
return state
}
func clonePlanPhases(phases []PlanPhase) []PlanPhase {
if len(phases) == 0 {
return nil
}
result := make([]PlanPhase, 0, len(phases))
for _, p := range phases {
phase := PlanPhase{Number: p.Number, Title: p.Title}
if len(p.Steps) > 0 {
phase.Steps = append([]PlanStep(nil), p.Steps...)
}
result = append(result, phase)
}
return result
}
// ClearLongTerm removes the long-term memory file.
func (ms *MemoryStore) ClearLongTerm() error {
if err := os.Remove(ms.memoryFile); err != nil && !os.IsNotExist(err) {
return err
}
ms.InvalidateCache()
return nil
}
// ---------- Plan state query methods ----------
var (
reActivePlan = regexp.MustCompile(`(?m)^# Active Plan`)
reStatus = regexp.MustCompile(`(?m)^> Status:\s*(.+)`)
rePhase = regexp.MustCompile(`(?m)^> Phase:\s*(\d+)`)
rePhaseHeader = regexp.MustCompile(`(?m)^## Phase (\d+):\s*(.*)`)
reWorkDir = regexp.MustCompile(`(?m)^> WorkDir:\s*(.+)`)
reTaskLine = regexp.MustCompile(`(?m)^> Task:\s*(.+)`)
)
// HasActivePlan returns true if MEMORY.md contains an active plan.
func (ms *MemoryStore) HasActivePlan() bool {
return ms.getParsedPlanState().hasActivePlan
}
// GetPlanStatus returns the plan status: "interviewing", "executing", or "".
func (ms *MemoryStore) GetPlanStatus() string {
return ms.getParsedPlanState().status
}
// GetCurrentPhase returns the current phase number from "> Phase: N".
func (ms *MemoryStore) GetCurrentPhase() int {
return ms.getParsedPlanState().currentPhase
}
// GetTotalPhases returns the total number of phases (max ## Phase N).
func (ms *MemoryStore) GetTotalPhases() int {
return ms.getParsedPlanState().totalPhases
}
// IsPlanComplete returns true if all steps in all phases are [x].
func (ms *MemoryStore) IsPlanComplete() bool {
phases := ms.getParsedPlanState().phases
if len(phases) == 0 {
return false
}
hasSteps := false
for _, p := range phases {
for _, s := range p.Steps {
hasSteps = true
if !s.Done {
return false
}
}
}
return hasSteps
}
// IsCurrentPhaseComplete returns true if all steps in the current phase are [x].
func (ms *MemoryStore) IsCurrentPhaseComplete() bool {
state := ms.getParsedPlanState()
if state.currentPhase == 0 {
return false
}
for _, p := range state.phases {
if p.Number == state.currentPhase {
if len(p.Steps) == 0 {
return false
}
for _, s := range p.Steps {
if !s.Done {
return false
}
}
return true
}
}
return false
}
func (ms *MemoryStore) extractPhaseContent(content string, phase int) string {
lines := strings.Split(content, "\n")
inPhase := false
var result []string
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
for _, line := range lines {
if strings.HasPrefix(line, phasePrefix) {
inPhase = true
continue
}
if inPhase {
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
break
}
result = append(result, line)
}
}
return strings.Join(result, "\n")
}
// PlanPhase represents a phase with its steps, for structured API output.
type PlanPhase struct {
Number int `json:"number"`
Title string `json:"title"`
Steps []PlanStep `json:"steps"`
}
// PlanStep represents a single step within a phase.
type PlanStep struct {
Index int `json:"index"` // 1-based within the phase
Description string `json:"description"`
Done bool `json:"done"`
}
// GetPlanPhases parses MEMORY.md and returns all phases with their steps.
func (ms *MemoryStore) GetPlanPhases() []PlanPhase {
return clonePlanPhases(ms.getParsedPlanState().phases)
}
func (ms *MemoryStore) getPlanPhasesFrom(content string) []PlanPhase {
if !reActivePlan.MatchString(content) {
return nil
}
totalPhases := maxPhaseNumber(content)
phases := make([]PlanPhase, 0, totalPhases)
for p := 1; p <= totalPhases; p++ {
title := ms.getPhaseTitle(content, p)
phaseContent := ms.extractPhaseContent(content, p)
var steps []PlanStep
stepIdx := 0
for _, line := range strings.Split(phaseContent, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "- [x] ") {
stepIdx++
steps = append(steps, PlanStep{Index: stepIdx, Description: line[6:], Done: true})
} else if strings.HasPrefix(line, "- [ ] ") {
stepIdx++
steps = append(steps, PlanStep{Index: stepIdx, Description: line[6:], Done: false})
}
}
phases = append(phases, PlanPhase{Number: p, Title: title, Steps: steps})
}
return phases
}
// ---------- Plan mutation methods ----------
// SetStatus sets the plan status (interviewing or executing).
func (ms *MemoryStore) SetStatus(status string) error {
content := ms.ReadLongTerm()
if m := reStatus.FindString(content); m != "" {
content = strings.Replace(content, m, "> Status: "+status, 1)
}
return ms.WriteLongTerm(content)
}
// AdvancePhase increments the current phase number by 1.
func (ms *MemoryStore) AdvancePhase() error {
content := ms.ReadLongTerm()
m := rePhase.FindStringSubmatch(content)
if len(m) < 2 {
return fmt.Errorf("no phase marker found")
}
current, _ := strconv.Atoi(m[1])
next := current + 1
content = strings.Replace(content, m[0], fmt.Sprintf("> Phase: %d", next), 1)
return ms.WriteLongTerm(content)
}
// SetPhase sets the current phase number to n.
func (ms *MemoryStore) SetPhase(n int) error {
content := ms.ReadLongTerm()
m := rePhase.FindString(content)
if m == "" {
return fmt.Errorf("no phase marker found")
}
content = strings.Replace(content, m, fmt.Sprintf("> Phase: %d", n), 1)
return ms.WriteLongTerm(content)
}
// MarkStep marks the nth step (1-based) in the given phase as done [x].
func (ms *MemoryStore) MarkStep(phase, step int) error {
content := ms.ReadLongTerm()
lines := strings.Split(content, "\n")
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
inPhase := false
stepCount := 0
for i, line := range lines {
if strings.HasPrefix(line, phasePrefix) {
inPhase = true
continue
}
if inPhase {
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
break
}
if strings.HasPrefix(line, "- [ ] ") {
stepCount++
if stepCount == step {
lines[i] = strings.Replace(line, "- [ ] ", "- [x] ", 1)
return ms.WriteLongTerm(strings.Join(lines, "\n"))
}
}
}
}
return fmt.Errorf("step %d not found in phase %d", step, phase)
}
// AddStep appends a new step to the given phase.
func (ms *MemoryStore) AddStep(phase int, desc string) error {
content := ms.ReadLongTerm()
lines := strings.Split(content, "\n")
phasePrefix := fmt.Sprintf("## Phase %d:", phase)
inPhase := false
insertIdx := -1
for i, line := range lines {
if strings.HasPrefix(line, phasePrefix) {
inPhase = true
continue
}
if inPhase {
if strings.HasPrefix(line, "## Phase ") || strings.HasPrefix(line, "## Context") {
insertIdx = i
break
}
if strings.HasPrefix(line, "- [") {
insertIdx = i + 1
}
}
}
if insertIdx < 0 {
if inPhase {
insertIdx = len(lines)
} else {
return fmt.Errorf("phase %d not found", phase)
}
}
newStep := "- [ ] " + desc
newLines := make([]string, 0, len(lines)+1)
newLines = append(newLines, lines[:insertIdx]...)
newLines = append(newLines, newStep)
newLines = append(newLines, lines[insertIdx:]...)
return ms.WriteLongTerm(strings.Join(newLines, "\n"))
}
// ValidatePlanStructure checks that the plan has valid structure for
// transitioning out of the interview phase.
func (ms *MemoryStore) ValidatePlanStructure() error {
content := ms.ReadLongTerm()
if !reActivePlan.MatchString(content) {
return fmt.Errorf("missing '# Active Plan' header")
}
if !reStatus.MatchString(content) {
return fmt.Errorf("missing '> Status:' line")
}
if !rePhase.MatchString(content) {
return fmt.Errorf("missing '> Phase:' line")
}
phases := ms.getPlanPhasesFrom(content)
if len(phases) == 0 {
return fmt.Errorf("no '## Phase N:' sections found")
}
for _, p := range phases {
if len(p.Steps) == 0 {
return fmt.Errorf("Phase %d has no checkbox steps (use '- [ ] ...')", p.Number)
}
}
return nil
}
// ---------- Selective injection methods ----------
// GetPlanWorkDir returns the WorkDir from the plan metadata, or "".
func (ms *MemoryStore) GetPlanWorkDir() string {
return ms.getParsedPlanState().workDir
}
// GetPlanTaskName returns the task description from the plan metadata, or "".
func (ms *MemoryStore) GetPlanTaskName() string {
return ms.getParsedPlanState().taskName
}
const interviewSeedTemplate = `# Active Plan
> Task: %s
> WorkDir: %s
> Status: interviewing
> Phase: 1
`
// BuildInterviewSeed creates the initial plan seed for a given task description.
func BuildInterviewSeed(task, workDir string) string {
return fmt.Sprintf(interviewSeedTemplate, task, workDir)
}
// GetInterviewContext returns context for injection during the interviewing phase.
func (ms *MemoryStore) GetInterviewContext() string {
return ms.getInterviewContextFrom(ms.ReadLongTerm())
}
func (ms *MemoryStore) getInterviewContextFrom(content string) string {
var sb strings.Builder
sb.WriteString("## Active Plan (interviewing)\n\n")
sb.WriteString(content)
sb.WriteString("\n\n### Interview Guide\n")
sb.WriteString("Ask about:\n")
sb.WriteString("- Goals and success criteria\n")
sb.WriteString("- Constraints (time, budget, platform)\n")
sb.WriteString("- Environment (OS, language, runtime versions)\n")
sb.WriteString("- Tooling preferences (test framework, linter, formatter, CI)\n")
sb.WriteString("- Key commands the user already runs (build, test, deploy)\n")
sb.WriteString("\n### Rules\n")
sb.WriteString(
"- NEVER remove or overwrite the header block (`# Active Plan`, `> Task:`, `> Status:`, `> Phase:` lines). The system parses these to track state.\n",
)
sb.WriteString(
"- After each answer, use edit_file to append findings to the ## Context section of memory/MEMORY.md.\n",
)
sb.WriteString(
"- When you have enough information, use edit_file to add ## Phase, ## Commands, and ## Context sections BELOW the header block.\n",
)
sb.WriteString(
"- Each step MUST use checkbox syntax: `- [ ] description`. The system parses checkboxes to track progress.\n",
)
sb.WriteString("- Organize into 2-5 phases with 3-5 steps each.\n")
sb.WriteString(
"- After writing Phases, change `> Status: interviewing` to `> Status: review` via edit_file. The user must approve with /plan start before execution begins.\n",
)
sb.WriteString("\n### Target Format (MANDATORY — system parses this exact structure)\n\n")
sb.WriteString("# Active Plan\n")
sb.WriteString("> Task: <description>\n")
sb.WriteString("> WorkDir: <path>\n")
sb.WriteString("> Status: interviewing\n")
sb.WriteString("> Phase: 1\n\n")
sb.WriteString("## Phase 1: <title>\n")
sb.WriteString("- [ ] Step description\n")
sb.WriteString("- [ ] Step description\n\n")
sb.WriteString("## Phase 2: <title>\n")
sb.WriteString("- [ ] Step description\n")
sb.WriteString("- [ ] Step description\n\n")
sb.WriteString("## Commands\n")
sb.WriteString("build: <project-specific build command>\n")
sb.WriteString("test: <project-specific test command>\n")
sb.WriteString("lint: <project-specific lint command>\n\n")
sb.WriteString("## Context\n")
sb.WriteString("<collected requirements, decisions, environment>\n")
return sb.String()
}
// GetReviewContext returns context for injection during the review phase.
func (ms *MemoryStore) GetReviewContext() string {
return ms.getReviewContextFrom(ms.ReadLongTerm())
}
func (ms *MemoryStore) getReviewContextFrom(content string) string {
var sb strings.Builder
sb.WriteString("## Active Plan (awaiting approval)\n\n")
sb.WriteString(content)
sb.WriteString("\n\nThe plan is awaiting user approval.\n")
sb.WriteString("- If the user requests changes, update memory/MEMORY.md via edit_file.\n")
sb.WriteString("- Do NOT change Status yourself. The user will run /plan start to approve.\n")
return sb.String()
}
// GetPlanContext returns context for injection during the executing phase.
func (ms *MemoryStore) GetPlanContext() string {
return ms.getPlanContextFrom(ms.ReadLongTerm())
}
func (ms *MemoryStore) getPlanContextFrom(content string) string {
var currentPhase int
if m := rePhase.FindStringSubmatch(content); len(m) >= 2 {
currentPhase, _ = strconv.Atoi(m[1])
}
totalPhases := maxPhaseNumber(content)
taskLine := ""
if m := reTaskLine.FindStringSubmatch(content); len(m) >= 2 {
taskLine = strings.TrimSpace(m[1])
}
var sb strings.Builder
sb.WriteString("## Active Plan\n")
fmt.Fprintf(&sb, "Task: %s | Phase %d/%d\n", taskLine, currentPhase, totalPhases)
for p := 1; p < currentPhase; p++ {
title := ms.getPhaseTitle(content, p)
fmt.Fprintf(&sb, "Done: Phase %d (%s)\n", p, title)
}
if currentPhase > 0 {
title := ms.getPhaseTitle(content, currentPhase)
fmt.Fprintf(&sb, "### Current: Phase %d — %s\n", currentPhase, title)
phaseContent := ms.extractPhaseContent(content, currentPhase)
sb.WriteString(strings.TrimSpace(phaseContent))
sb.WriteString("\n")
}
if commandsContent := ms.extractCommandsSection(content); commandsContent != "" {
sb.WriteString("### Commands\n")
sb.WriteString(commandsContent)
sb.WriteString("\n")
}
if contextContent := ms.extractContextSection(content); contextContent != "" {
sb.WriteString("### Context\n")
sb.WriteString(contextContent)
sb.WriteString("\n")
}
if orchContent := ms.extractSection(content, "Orchestration"); orchContent != "" {
sb.WriteString("### Orchestration\n")
sb.WriteString(orchContent)
sb.WriteString("\n")
}
return sb.String()
}
func maxPhaseNumber(content string) int {
matches := rePhaseHeader.FindAllStringSubmatch(content, -1)
maxN := 0
for _, m := range matches {
if len(m) >= 2 {
n, _ := strconv.Atoi(m[1])
if n > maxN {
maxN = n
}
}
}
return maxN
}
func (ms *MemoryStore) getPhaseTitle(content string, phase int) string {
matches := rePhaseHeader.FindAllStringSubmatch(content, -1)
for _, m := range matches {
if len(m) >= 3 {
n, _ := strconv.Atoi(m[1])
if n == phase {
return strings.TrimSpace(m[2])
}
}
}
return ""
}
func (ms *MemoryStore) extractSection(content, name string) string {
lines := strings.Split(content, "\n")
prefix := "## " + name
inSection := false
var result []string
for _, line := range lines {
if strings.HasPrefix(line, prefix) {
inSection = true
continue
}
if inSection {
if strings.HasPrefix(line, "## ") {
break
}
result = append(result, line)
}
}
return strings.TrimSpace(strings.Join(result, "\n"))
}
func (ms *MemoryStore) extractContextSection(content string) string {
return ms.extractSection(content, "Context")
}
func (ms *MemoryStore) extractCommandsSection(content string) string {
return ms.extractSection(content, "Commands")
}
// FormatPlanDisplay returns a user-facing display of the full plan with emoji indicators.
func (ms *MemoryStore) FormatPlanDisplay() string {
state := ms.getParsedPlanState()
if !state.hasActivePlan {
return "No active plan."
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Plan: %s\n", state.taskName))
sb.WriteString(fmt.Sprintf("Status: %s | Phase %d/%d\n\n", state.status, state.currentPhase, len(state.phases)))
for _, p := range state.phases {
var emoji string
if p.Number < state.currentPhase {
emoji = "\u2705"
} else if p.Number == state.currentPhase {
emoji = "\u25B6\uFE0F"
} else {
emoji = "\u23F3"
}
sb.WriteString(fmt.Sprintf("%s Phase %d: %s\n", emoji, p.Number, p.Title))
if p.Number <= state.currentPhase {
for _, s := range p.Steps {
if s.Done {
sb.WriteString(" \u2611 " + s.Description + "\n")
} else {
sb.WriteString(" \u2610 " + s.Description + "\n")
}
}
}
}
if commandsContent := ms.extractCommandsSection(state.content); commandsContent != "" {
sb.WriteString("\nCommands:\n")
for _, line := range strings.Split(commandsContent, "\n") {
line = strings.TrimSpace(line)
if line != "" {
sb.WriteString(" " + line + "\n")
}
}
}
if contextContent := ms.extractContextSection(state.content); contextContent != "" {
sb.WriteString("\nContext: " + contextContent + "\n")
}
return sb.String()
}
// ---------- GetMemoryContext (plan-aware) ----------
func (ms *MemoryStore) getMemoryContextPlanAware() string {
var parts []string
state := ms.getParsedPlanState()
longTerm := state.content
if longTerm != "" {
if state.hasActivePlan {
switch state.status {
case "interviewing":
parts = append(parts, ms.getInterviewContextFrom(longTerm))
case "review":
parts = append(parts, ms.getReviewContextFrom(longTerm))
default:
parts = append(parts, ms.getPlanContextFrom(longTerm))
}
} else {
parts = append(parts, "## Long-term Memory\n\n"+longTerm)
}
}
// Suppress daily notes when a plan is active to save context
if !state.hasActivePlan {
recentNotes := ms.GetRecentDailyNotes(3)
if recentNotes != "" {
parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "\n\n---\n\n")
}

View file

@ -10,18 +10,13 @@ type mockProvider struct{}
func (m *mockProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
opts map[string]any,
) (*providers.LLMResponse, error) {
return &providers.LLMResponse{
Content: "Mock response",
Content: "Mock response",
ToolCalls: []providers.ToolCall{},
}, nil
}

View file

@ -11,62 +11,43 @@ import (
)
// AgentRegistry manages multiple agent instances and routes messages to them.
type AgentRegistry struct {
agents map[string]*AgentInstance
agents map[string]*AgentInstance
resolver *routing.RouteResolver
mu sync.RWMutex
mu sync.RWMutex
}
// NewAgentRegistry creates a registry from config, instantiating all agents.
func NewAgentRegistry(
cfg *config.Config,
provider providers.LLMProvider,
) *AgentRegistry {
registry := &AgentRegistry{
agents: make(map[string]*AgentInstance),
agents: make(map[string]*AgentInstance),
resolver: routing.NewRouteResolver(cfg),
}
agentConfigs := cfg.Agents.List
if len(agentConfigs) == 0 {
implicitAgent := &config.AgentConfig{
ID: "main",
ID: "main",
Default: true,
}
instance := NewAgentInstance(implicitAgent, &cfg.Agents.Defaults, cfg, provider)
registry.agents["main"] = instance
logger.InfoCF("agent", "Created implicit main agent (no agents.list configured)", nil)
} else {
for i := range agentConfigs {
ac := &agentConfigs[i]
id := routing.NormalizeAgentID(ac.ID)
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
registry.agents[id] = instance
logger.InfoCF("agent", "Registered agent",
map[string]any{
"agent_id": id,
"name": ac.Name,
"agent_id": id,
"name": ac.Name,
"workspace": instance.Workspace,
"model": instance.Model,
"model": instance.Model,
})
}
}
@ -75,66 +56,48 @@ func NewAgentRegistry(
}
// GetAgent returns the agent instance for a given ID.
func (r *AgentRegistry) GetAgent(agentID string) (*AgentInstance, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
id := routing.NormalizeAgentID(agentID)
agent, ok := r.agents[id]
return agent, ok
}
// ResolveRoute determines which agent handles the message.
func (r *AgentRegistry) ResolveRoute(input routing.RouteInput) routing.ResolvedRoute {
return r.resolver.ResolveRoute(input)
}
// ListAgentIDs returns all registered agent IDs.
func (r *AgentRegistry) ListAgentIDs() []string {
r.mu.RLock()
defer r.mu.RUnlock()
ids := make([]string, 0, len(r.agents))
for id := range r.agents {
ids = append(ids, id)
}
return ids
}
// CanSpawnSubagent checks if parentAgentID is allowed to spawn targetAgentID.
func (r *AgentRegistry) CanSpawnSubagent(parentAgentID, targetAgentID string) bool {
parent, ok := r.GetAgent(parentAgentID)
if !ok {
return false
}
if parent.Subagents == nil || parent.Subagents.AllowAgents == nil {
return false
}
targetNorm := routing.NormalizeAgentID(targetAgentID)
for _, allowed := range parent.Subagents.AllowAgents {
if allowed == "*" {
return true
}
if routing.NormalizeAgentID(allowed) == targetNorm {
return true
}
}
return false
}
@ -151,20 +114,27 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) {
}
}
// GetDefaultAgent returns the default agent instance.
// Close releases resources held by all registered agents.
func (r *AgentRegistry) Close() {
r.mu.RLock()
defer r.mu.RUnlock()
for _, agent := range r.agents {
if err := agent.Close(); err != nil {
logger.WarnCF("agent", "Failed to close agent",
map[string]any{"agent_id": agent.ID, "error": err.Error()})
}
}
}
// GetDefaultAgent returns the default agent instance.
func (r *AgentRegistry) GetDefaultAgent() *AgentInstance {
r.mu.RLock()
defer r.mu.RUnlock()
if agent, ok := r.agents["main"]; ok {
return agent
}
for _, agent := range r.agents {
return agent
}
return nil
}

View file

@ -12,13 +12,9 @@ type mockRegistryProvider struct{}
func (m *mockRegistryProvider) Chat(
ctx context.Context,
messages []providers.Message,
tools []providers.ToolDefinition,
model string,
options map[string]any,
) (*providers.LLMResponse, error) {
return &providers.LLMResponse{Content: "mock", FinishReason: "stop"}, nil
@ -32,15 +28,11 @@ func testCfg(agents []config.AgentConfig) *config.Config {
return &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: "/tmp/picoclaw-test-registry",
Model: "gpt-4",
MaxTokens: 8192,
Workspace: "/tmp/picoclaw-test-registry",
Model: "gpt-4",
MaxTokens: 8192,
MaxToolIterations: 10,
},
List: agents,
},
}
@ -48,21 +40,17 @@ func testCfg(agents []config.AgentConfig) *config.Config {
func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
cfg := testCfg(nil)
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
ids := registry.ListAgentIDs()
if len(ids) != 1 || ids[0] != "main" {
t.Errorf("expected implicit main agent, got %v", ids)
}
agent, ok := registry.GetAgent("main")
if !ok || agent == nil {
t.Fatal("expected to find 'main' agent")
}
if agent.ID != "main" {
t.Errorf("agent.ID = %q, want 'main'", agent.ID)
}
@ -71,30 +59,24 @@ func TestNewAgentRegistry_ImplicitMain(t *testing.T) {
func TestNewAgentRegistry_ExplicitAgents(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{ID: "sales", Default: true, Name: "Sales Bot"},
{ID: "support", Name: "Support Bot"},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
ids := registry.ListAgentIDs()
if len(ids) != 2 {
t.Fatalf("expected 2 agents, got %d: %v", len(ids), ids)
}
sales, ok := registry.GetAgent("sales")
if !ok || sales == nil {
t.Fatal("expected to find 'sales' agent")
}
if sales.Name != "Sales Bot" {
t.Errorf("sales.Name = %q, want 'Sales Bot'", sales.Name)
}
support, ok := registry.GetAgent("support")
if !ok || support == nil {
t.Fatal("expected to find 'support' agent")
}
@ -104,15 +86,12 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{ID: "my-agent", Default: true},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, ok := registry.GetAgent("My-Agent")
if !ok || agent == nil {
t.Fatal("expected to find agent with normalized ID")
}
if agent.ID != "my-agent" {
t.Errorf("agent.ID = %q, want 'my-agent'", agent.ID)
}
@ -121,16 +100,12 @@ func TestAgentRegistry_GetAgent_Normalize(t *testing.T) {
func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{ID: "alpha"},
{ID: "beta", Default: true},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
// GetDefaultAgent first checks for "main", then returns any
agent := registry.GetDefaultAgent()
if agent == nil {
t.Fatal("expected a default agent")
}
@ -139,36 +114,27 @@ func TestAgentRegistry_GetDefaultAgent(t *testing.T) {
func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{
ID: "parent",
ID: "parent",
Default: true,
Subagents: &config.SubagentsConfig{
AllowAgents: []string{"child1", "child2"},
},
},
{ID: "child1"},
{ID: "child2"},
{ID: "restricted"},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
if !registry.CanSpawnSubagent("parent", "child1") {
t.Error("expected parent to be allowed to spawn child1")
}
if !registry.CanSpawnSubagent("parent", "child2") {
t.Error("expected parent to be allowed to spawn child2")
}
if registry.CanSpawnSubagent("parent", "restricted") {
t.Error("expected parent to NOT be allowed to spawn restricted")
}
if registry.CanSpawnSubagent("child1", "child2") {
t.Error("expected child1 to NOT be allowed to spawn (no subagents config)")
}
@ -177,24 +143,19 @@ func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{
ID: "admin",
ID: "admin",
Default: true,
Subagents: &config.SubagentsConfig{
AllowAgents: []string{"*"},
},
},
{ID: "any-agent"},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
if !registry.CanSpawnSubagent("admin", "any-agent") {
t.Error("expected wildcard to allow spawning any agent")
}
if !registry.CanSpawnSubagent("admin", "nonexistent") {
t.Error("expected wildcard to allow spawning even nonexistent agents")
}
@ -202,15 +163,12 @@ func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
func TestAgentInstance_Model(t *testing.T) {
model := &config.AgentModelConfig{Primary: "claude-opus"}
cfg := testCfg([]config.AgentConfig{
{ID: "custom", Default: true, Model: model},
})
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, _ := registry.GetAgent("custom")
if agent.Model != "claude-opus" {
t.Errorf("agent.Model = %q, want 'claude-opus'", agent.Model)
}
@ -220,13 +178,10 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) {
cfg := testCfg([]config.AgentConfig{
{ID: "inherit", Default: true},
})
cfg.Agents.Defaults.ModelFallbacks = []string{"openai/gpt-4o-mini", "anthropic/haiku"}
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, _ := registry.GetAgent("inherit")
if len(agent.Fallbacks) != 2 {
t.Errorf("expected 2 fallbacks inherited from defaults, got %d", len(agent.Fallbacks))
}
@ -234,22 +189,16 @@ func TestAgentInstance_FallbackInheritance(t *testing.T) {
func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
model := &config.AgentModelConfig{
Primary: "gpt-4",
Primary: "gpt-4",
Fallbacks: []string{}, // explicitly empty = disable
}
cfg := testCfg([]config.AgentConfig{
{ID: "no-fallback", Default: true, Model: model},
})
cfg.Agents.Defaults.ModelFallbacks = []string{"should-not-inherit"}
registry := NewAgentRegistry(cfg, &mockRegistryProvider{})
agent, _ := registry.GetAgent("no-fallback")
if len(agent.Fallbacks) != 0 {
t.Errorf("expected 0 fallbacks (explicit empty), got %d: %v", len(agent.Fallbacks), agent.Fallbacks)
}

View file

@ -30,14 +30,15 @@ type InboundMessage struct {
}
type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
IsStatus bool `json:"is_status,omitempty"`
IsTaskStatus bool `json:"is_task_status,omitempty"`
TaskID string `json:"task_id,omitempty"`
Final bool `json:"final,omitempty"` // Finalize: send as permanent message, not draft
SkipPlaceholder bool `json:"skip_placeholder,omitempty"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
ReplyToMessageID string `json:"reply_to_message_id,omitempty"`
IsStatus bool `json:"is_status,omitempty"`
IsTaskStatus bool `json:"is_task_status,omitempty"`
TaskID string `json:"task_id,omitempty"`
Final bool `json:"final,omitempty"` // Finalize: send as permanent message, not draft
SkipPlaceholder bool `json:"skip_placeholder,omitempty"`
}
// MediaPart describes a single media attachment to send.

View file

@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
"regexp"
"strconv"
"strings"
"sync/atomic"
@ -32,6 +33,9 @@ func init() {
uniqueIDPrefix = hex.EncodeToString(b[:])
}
// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]).
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
// cryptographically secure — it must not be used in contexts where unpredictability matters.
@ -285,10 +289,10 @@ func (c *BaseChannel) HandleMessage(
}
}
// Placeholder — independent pipeline.
// Skip for DraftSender channels: the streaming draft bubble serves
// as the placeholder, and sendMessage on final response replaces it.
// Sending both a placeholder AND drafts causes duplicate chat bubbles.
if _, isDrafter := c.owner.(DraftSender); !isDrafter {
// Skip when the message contains audio: the agent will send the
// placeholder after transcription completes, so the user sees
// "Thinking…" only once the voice has been processed.
if !audioAnnotationRe.MatchString(content) {
if pc, ok := c.owner.(PlaceholderCapable); ok {
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)

View file

@ -10,6 +10,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
@ -39,6 +40,9 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
}
// Set the logger for the Stream SDK
dinglog.SetLogger(logger.NewLogger("dingtalk"))
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger),

View file

@ -45,6 +45,14 @@ type DiscordChannel struct {
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
discordgo.Logger = logger.NewLogger("discord").
WithLevels(map[int]logger.LogLevel{
discordgo.LogError: logger.ERROR,
discordgo.LogWarning: logger.WARN,
discordgo.LogInformational: logger.INFO,
discordgo.LogDebug: logger.DEBUG,
}).Log
session, err := discordgo.New("Bot " + cfg.Token)
if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err)
@ -134,7 +142,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return nil
}
return c.sendChunk(ctx, channelID, msg.Content)
return c.sendChunk(ctx, channelID, msg.Content, msg.ReplyToMessageID)
}
// SendMedia implements the channels.MediaSender interface.
@ -232,42 +240,6 @@ func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMes
}
}
// SendWithID implements channels.MessageSenderWithID.
// It sends a message and returns the platform message ID.
func (c *DiscordChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
if !c.IsRunning() {
return "", channels.ErrNotRunning
}
if chatID == "" {
return "", fmt.Errorf("channel ID is empty")
}
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
type result struct {
id string
err error
}
done := make(chan result, 1)
go func() {
msg, err := c.session.ChannelMessageSend(chatID, content)
if err != nil {
done <- result{"", fmt.Errorf("discord send: %w", channels.ErrTemporary)}
} else {
done <- result{msg.ID, nil}
}
}()
select {
case r := <-done:
return r.id, r.err
case <-sendCtx.Done():
return "", sendCtx.Err()
}
}
// EditMessage implements channels.MessageEditor.
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
_, err := c.session.ChannelMessageEdit(chatID, messageID, content)
@ -295,14 +267,29 @@ func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (st
return msg.ID, nil
}
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) error {
// Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := c.session.ChannelMessageSend(channelID, content)
var err error
// If we have an ID, we send the message as "Reply"
if replyToID != "" {
_, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
Content: content,
Reference: &discordgo.MessageReference{
MessageID: replyToID,
ChannelID: channelID,
},
})
} else {
// Otherwise, we send a normal message
_, err = c.session.ChannelMessageSend(channelID, content)
}
done <- err
}()

View file

@ -4,11 +4,10 @@ package feishu
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"math/big"
"math/rand"
"net/http"
"os"
"path/filepath"
@ -201,18 +200,13 @@ func (c *FeishuChannel) SendPlaceholder(ctx context.Context, chatID string) (str
func (c *FeishuChannel) ReactToMessage(ctx context.Context, chatID, messageID string) (func(), error) {
// Get emoji list from config
emojiList := c.config.RandomReactionEmoji
var chosenEmoji string
if len(emojiList) == 0 {
// Default to "Pin" if no config
emojiList = []string{"Pin"}
}
// Randomly choose one from the list using crypto/rand for better distribution
idx, err := rand.Int(rand.Reader, big.NewInt(int64(len(emojiList))))
var chosenEmoji string
if err != nil {
chosenEmoji = emojiList[0]
chosenEmoji = "Pin"
} else {
chosenEmoji = emojiList[idx.Int64()]
idx := rand.Intn(len(emojiList))
chosenEmoji = emojiList[idx]
}
req := larkim.NewCreateMessageReactionReqBuilder().

View file

@ -26,12 +26,6 @@ type ReactionCapable interface {
ReactToMessage(ctx context.Context, chatID, messageID string) (undo func(), err error)
}
// MessageSenderWithID — channels that can send a message and return its platform-specific ID.
// Used by Manager to track status/task messages for later editing.
type MessageSenderWithID interface {
SendWithID(ctx context.Context, chatID string, content string) (messageID string, err error)
}
// PlaceholderCapable — channels that can send a placeholder message
// (e.g. "Thinking... 💭") that will later be edited to the actual response.
// The channel MUST also implement MessageEditor for the placeholder to be useful.
@ -41,13 +35,6 @@ type PlaceholderCapable interface {
SendPlaceholder(ctx context.Context, chatID string) (messageID string, err error)
}
// DraftSender — channels that can send progressive draft messages.
// Used for streaming LLM output without the "edited" indicator.
// draftID must be non-zero and consistent across updates for the same draft.
type DraftSender interface {
SendDraft(ctx context.Context, chatID string, draftID int, content string) error
}
// PlaceholderRecorder is injected into channels by Manager.
// Channels call these methods on inbound to register typing/placeholder state.
// Manager uses the registered state on outbound to stop typing and edit placeholders.

View file

@ -0,0 +1,16 @@
package channels
import "context"
// MessageSenderWithID — channels that can send a message and return its platform-specific ID.
// Used by Manager to track status/task messages for later editing.
type MessageSenderWithID interface {
SendWithID(ctx context.Context, chatID string, content string) (messageID string, err error)
}
// DraftSender — channels that can send progressive draft messages.
// Used for streaming LLM output without the "edited" indicator.
// draftID must be non-zero and consistent across updates for the same draft.
type DraftSender interface {
SendDraft(ctx context.Context, chatID string, draftID int, content string) error
}

View file

@ -32,6 +32,10 @@ const (
lineBotInfoEndpoint = lineAPIBase + "/info"
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
lineReplyTokenMaxAge = 25 * time.Second
// Limit request body to prevent memory exhaustion (DoS).
// LINE webhook payloads are typically a few KB; 1 MiB is generous.
maxWebhookBodySize = 1 << 20 // 1 MiB
)
type replyTokenEntry struct {
@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
return
}
body, err := io.ReadAll(r.Body)
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
if err != nil {
logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(),
@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
if int64(len(body)) > maxWebhookBodySize {
logger.WarnC("line", "Webhook request body too large, rejected")
http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
return
}
signature := r.Header.Get("X-Line-Signature")
if !c.verifySignature(body, signature) {

View file

@ -0,0 +1,81 @@
package line
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestWebhookRejectsOversizedBody(t *testing.T) {
ch := &LINEChannel{}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
}
}
func TestWebhookAcceptsMaxBodySize(t *testing.T) {
ch := &LINEChannel{}
body := bytes.Repeat([]byte("A"), maxWebhookBodySize)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(body))
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
// Missing signature should be rejected, but the body size should not trigger 413.
if rec.Code != http.StatusForbidden {
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
}
func TestWebhookRejectsOversizedBodyBeforeSignatureCheck(t *testing.T) {
ch := &LINEChannel{}
oversized := bytes.Repeat([]byte("A"), maxWebhookBodySize+1)
req := httptest.NewRequest(http.MethodPost, "/webhook", bytes.NewReader(oversized))
req.Header.Set("X-Line-Signature", "invalidsignature")
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Errorf("expected status %d, got %d", http.StatusRequestEntityTooLarge, rec.Code)
}
}
func TestWebhookRejectsNonPostMethod(t *testing.T) {
ch := &LINEChannel{}
req := httptest.NewRequest(http.MethodGet, "/webhook", nil)
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rec.Code)
}
}
func TestWebhookRejectsInvalidSignature(t *testing.T) {
ch := &LINEChannel{}
body := `{"events":[]}`
req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body))
req.Header.Set("X-Line-Signature", "invalidsignature")
rec := httptest.NewRecorder()
ch.webhookHandler(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("expected status %d, got %d", http.StatusForbidden, rec.Code)
}
}

View file

@ -79,6 +79,7 @@ var channelRateConfig = map[string]float64{
"slack": 1,
"matrix": 2,
"line": 10,
"qq": 5,
"irc": 2,
}
@ -118,6 +119,27 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
m.placeholders.Store(key, placeholderEntry{id: placeholderID, createdAt: time.Now()})
}
// SendPlaceholder sends a "Thinking..." placeholder for the given channel/chatID
// and records it for later editing. Returns true if a placeholder was sent.
func (m *Manager) SendPlaceholder(ctx context.Context, channel, chatID string) bool {
m.mu.RLock()
ch, ok := m.channels[channel]
m.mu.RUnlock()
if !ok {
return false
}
pc, ok := ch.(PlaceholderCapable)
if !ok {
return false
}
phID, err := pc.SendPlaceholder(ctx, chatID)
if err != nil || phID == "" {
return false
}
m.RecordPlaceholder(channel, chatID, phID)
return true
}
// RecordTypingStop registers a typing stop function for later invocation.
// Implements PlaceholderRecorder.
//
@ -127,12 +149,12 @@ func (m *Manager) RecordPlaceholder(channel, chatID, placeholderID string) {
// consuming the *new* message's typing entry.
func (m *Manager) RecordTypingStop(channel, chatID string, stop func()) {
key := channel + ":" + chatID
if v, loaded := m.typingStops.Load(key); loaded {
if entry, ok := v.(typingEntry); ok {
entry.stop() // idempotent
entry := typingEntry{stop: stop, createdAt: time.Now()}
if previous, loaded := m.typingStops.Swap(key, entry); loaded {
if oldEntry, ok := previous.(typingEntry); ok && oldEntry.stop != nil {
oldEntry.stop()
}
}
m.typingStops.Store(key, typingEntry{stop: stop, createdAt: time.Now()})
}
// RecordReactionUndo registers a reaction undo function for later invocation.
@ -1122,6 +1144,39 @@ func (m *Manager) UnregisterChannel(name string) {
delete(m.channels, name)
}
// SendMessage sends an outbound message synchronously through the channel
// worker's rate limiter and retry logic. It blocks until the message is
// delivered (or all retries are exhausted), which preserves ordering when
// a subsequent operation depends on the message having been sent.
func (m *Manager) SendMessage(ctx context.Context, msg bus.OutboundMessage) error {
m.mu.RLock()
_, exists := m.channels[msg.Channel]
w, wExists := m.workers[msg.Channel]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("channel %s not found", msg.Channel)
}
if !wExists || w == nil {
return fmt.Errorf("channel %s has no active worker", msg.Channel)
}
maxLen := 0
if mlp, ok := w.ch.(MessageLengthProvider); ok {
maxLen = mlp.MaxMessageLength()
}
if maxLen > 0 && len([]rune(msg.Content)) > maxLen {
for _, chunk := range SplitMessage(msg.Content, maxLen) {
chunkMsg := msg
chunkMsg.Content = chunk
m.sendWithRetry(ctx, msg.Channel, w, chunkMsg)
}
} else {
m.sendWithRetry(ctx, msg.Channel, w, msg)
}
return nil
}
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
m.mu.RLock()
_, exists := m.channels[channelName]

View file

@ -0,0 +1,895 @@
package channels
import (
"context"
"fmt"
"sync/atomic"
"testing"
"time"
"golang.org/x/time/rate"
"github.com/sipeed/picoclaw/pkg/bus"
)
// mockEditorWithSendID implements MessageEditor and MessageSenderWithID.
type mockEditorWithSendID struct {
mockChannel
editFn func(ctx context.Context, chatID, messageID, content string) error
sendWithID func(ctx context.Context, chatID, content string) (string, error)
}
func (m *mockEditorWithSendID) EditMessage(
ctx context.Context, chatID, messageID, content string,
) error {
return m.editFn(ctx, chatID, messageID, content)
}
func (m *mockEditorWithSendID) SendWithID(ctx context.Context, chatID, content string) (string, error) {
return m.sendWithID(ctx, chatID, content)
}
func TestHandleStatusSend_EditsPlaceholder(t *testing.T) {
m := newTestManager()
var editCalled bool
var editedContent string
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, content string) error {
editCalled = true
editedContent = content
if messageID != "ph-42" {
t.Fatalf("expected messageID ph-42, got %s", messageID)
}
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when placeholder exists")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
m.RecordPlaceholder("test", "123", "ph-42")
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "status update 1", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !editCalled {
t.Fatal("expected EditMessage to be called on placeholder")
}
if editedContent != "status update 1" {
t.Fatalf("expected content 'status update 1', got %s", editedContent)
}
}
func TestHandleStatusSend_EditsTrackedStatus(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCalled = true
if messageID != "status-99" {
t.Fatalf("expected messageID status-99, got %s", messageID)
}
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when statusMsgID exists")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-99", createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "update 2", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !editCalled {
t.Fatal("expected EditMessage to be called on tracked status message")
}
}
func TestHandleStatusSend_SendsNewAndTracks(t *testing.T) {
m := newTestManager()
var sendWithIDCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, _, _ string) error {
return nil
},
sendWithID: func(_ context.Context, chatID, content string) (string, error) {
sendWithIDCalled = true
if chatID != "123" {
t.Fatalf("expected chatID 123, got %s", chatID)
}
return "new-msg-1", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "first status", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !sendWithIDCalled {
t.Fatal("expected SendWithID to be called")
}
v, ok := m.statusMsgIDs.Load("test:123")
if !ok {
t.Fatal("expected statusMsgIDs to contain tracked entry")
}
entry := v.(statusMsgEntry)
if entry.messageID != "new-msg-1" {
t.Fatalf("expected messageID new-msg-1, got %s", entry.messageID)
}
}
func TestHandleTaskStatusSend_EditsExisting(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, content string) error {
editCalled = true
if messageID != "task-msg-1" {
t.Fatalf("expected messageID task-msg-1, got %s", messageID)
}
if content != "task progress 50%" {
t.Fatalf("expected content 'task progress 50%%', got %s", content)
}
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when task message exists")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
m.taskMsgIDs.Store(
taskStatusKey("test", "123", "task-abc"),
statusMsgEntry{messageID: "task-msg-1", createdAt: time.Now()},
)
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task progress 50%",
IsTaskStatus: true,
TaskID: "task-abc",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !editCalled {
t.Fatal("expected EditMessage to be called")
}
}
func TestHandleTaskStatusSend_SendsNewAndTracks(t *testing.T) {
m := newTestManager()
var sendWithIDCalled bool
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCalled = true
return "new-task-msg", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task started",
IsTaskStatus: true,
TaskID: "task-xyz",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !sendWithIDCalled {
t.Fatal("expected SendWithID to be called")
}
v, ok := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-xyz"))
if !ok {
t.Fatal("expected taskMsgIDs to contain tracked entry")
}
entry := v.(statusMsgEntry)
if entry.messageID != "new-task-msg" {
t.Fatalf("expected messageID new-task-msg, got %s", entry.messageID)
}
}
func TestHandleTaskStatusSend_FallbackToSend(t *testing.T) {
m := newTestManager()
var sendCalled bool
ch := &mockChannel{
sendFn: func(_ context.Context, msg bus.OutboundMessage) error {
sendCalled = true
if msg.Content != "task status" {
t.Fatalf("expected content 'task status', got %s", msg.Content)
}
return nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task status",
IsTaskStatus: true,
TaskID: "task-fallback",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !sendCalled {
t.Fatal("expected fallback Send to be called")
}
}
func TestPreSend_EditsStatusMessage(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockMessageEditor{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCalled = true
if messageID != "status-msg-77" {
t.Fatalf("expected messageID status-msg-77, got %s", messageID)
}
return nil
},
}
m.statusMsgIDs.Store("test:123", statusMsgEntry{messageID: "status-msg-77", createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
edited := m.preSend(context.Background(), "test", msg, ch)
if !edited {
t.Fatal("expected preSend to return true (status message edited)")
}
if !editCalled {
t.Fatal("expected EditMessage to be called")
}
if _, loaded := m.statusMsgIDs.Load("test:123"); loaded {
t.Fatal("expected statusMsgIDs entry to be deleted after preSend")
}
}
func TestRunWorker_RoutesStatusMessages(t *testing.T) {
m := newTestManager()
var regularSendCount atomic.Int32
var sendWithIDCount atomic.Int32
ch := &mockEditorWithSendID{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
regularSendCount.Add(1)
return nil
},
},
editFn: func(_ context.Context, _, _, _ string) error {
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCount.Add(1)
return "tracked-1", nil
},
}
w := &channelWorker{
ch: ch,
queue: make(chan bus.OutboundMessage, 10),
done: make(chan struct{}),
limiter: rate.NewLimiter(rate.Inf, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go m.runWorker(ctx, "test", w)
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "1", Content: "status", IsStatus: true}
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "2", Content: "task", IsTaskStatus: true, TaskID: "t1"}
w.queue <- bus.OutboundMessage{Channel: "test", ChatID: "3", Content: "hello"}
time.Sleep(200 * time.Millisecond)
if regularSendCount.Load() != 1 {
t.Fatalf("expected 1 regular Send call, got %d", regularSendCount.Load())
}
if sendWithIDCount.Load() != 2 {
t.Fatalf("expected 2 SendWithID calls (status + task), got %d", sendWithIDCount.Load())
}
}
func TestStatusMsgTTLJanitor(t *testing.T) {
m := newTestManager()
m.statusMsgIDs.Store("test:old", statusMsgEntry{
messageID: "old-status",
createdAt: time.Now().Add(-10 * time.Minute),
})
m.taskMsgIDs.Store("task-old", statusMsgEntry{
messageID: "old-task",
createdAt: time.Now().Add(-60 * time.Minute),
})
m.statusMsgIDs.Store("test:fresh", statusMsgEntry{
messageID: "fresh-status",
createdAt: time.Now(),
})
now := time.Now()
m.statusMsgIDs.Range(func(key, value any) bool {
if entry, ok := value.(statusMsgEntry); ok {
if now.Sub(entry.createdAt) > statusMsgTTL {
m.statusMsgIDs.Delete(key)
}
}
return true
})
m.taskMsgIDs.Range(func(key, value any) bool {
if entry, ok := value.(statusMsgEntry); ok {
if now.Sub(entry.createdAt) > taskMsgTTL {
m.taskMsgIDs.Delete(key)
}
}
return true
})
if _, loaded := m.statusMsgIDs.Load("test:old"); loaded {
t.Fatal("expected old status entry to be evicted")
}
if _, loaded := m.taskMsgIDs.Load("task-old"); loaded {
t.Fatal("expected old task entry to be evicted")
}
if _, loaded := m.statusMsgIDs.Load("test:fresh"); !loaded {
t.Fatal("expected fresh status entry to survive")
}
}
// mockDraftSender implements DraftSender + MessageSenderWithID + MessageEditor.
type mockDraftSender struct {
mockChannel
draftFn func(ctx context.Context, chatID string, draftID int, content string) error
editFn func(ctx context.Context, chatID, messageID, content string) error
sendWithID func(ctx context.Context, chatID, content string) (string, error)
}
func (m *mockDraftSender) EditMessage(
ctx context.Context, chatID, messageID, content string,
) error {
return m.editFn(ctx, chatID, messageID, content)
}
func (m *mockDraftSender) SendDraft(ctx context.Context, chatID string, draftID int, content string) error {
return m.draftFn(ctx, chatID, draftID, content)
}
func (m *mockDraftSender) SendWithID(ctx context.Context, chatID, content string) (string, error) {
return m.sendWithID(ctx, chatID, content)
}
func TestHandleStatusSend_UsesDraftSender(t *testing.T) {
m := newTestManager()
var draftCalled bool
var draftContent string
var draftDID int
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, chatID string, draftID int, content string) error {
draftCalled = true
draftContent = content
draftDID = draftID
return nil
},
editFn: func(_ context.Context, _, _, _ string) error {
t.Fatal("EditMessage should not be called when draft succeeds")
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when draft succeeds")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "streaming preview", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if !draftCalled {
t.Fatal("expected SendDraft to be called")
}
if draftContent != "streaming preview" {
t.Fatalf("expected draft content 'streaming preview', got %s", draftContent)
}
if draftDID == 0 {
t.Fatal("expected non-zero draftID")
}
draftCalled = false
var secondDID int
ch.draftFn = func(_ context.Context, _ string, draftID int, _ string) error {
draftCalled = true
secondDID = draftID
return nil
}
msg.Content = "streaming preview updated"
m.handleStatusSend(context.Background(), "test", w, msg)
if !draftCalled {
t.Fatal("expected SendDraft to be called again")
}
if secondDID != draftDID {
t.Fatalf("expected same draftID %d, got %d", draftDID, secondDID)
}
}
func TestHandleStatusSend_DraftFails_FallsToEdit(t *testing.T) {
m := newTestManager()
var editCalled bool
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
return fmt.Errorf("draft not supported in group")
},
editFn: func(_ context.Context, _, _, _ string) error {
editCalled = true
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
return "msg-1", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "preview", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
if editCalled {
t.Fatal("expected EditMessage NOT to be called (no placeholder)")
}
}
func TestHandleStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) {
m := newTestManager()
var sendWithIDCount int
var editCount int
var editedMessageID string
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
return fmt.Errorf("draft unsupported")
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCount++
editedMessageID = messageID
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCount++
return "msg-1", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{Channel: "test", ChatID: "group-main", Content: "preview-1", IsStatus: true}
m.handleStatusSend(context.Background(), "test", w, msg)
msg.Content = "preview-2"
m.handleStatusSend(context.Background(), "test", w, msg)
if sendWithIDCount != 1 {
t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount)
}
if editCount != 1 {
t.Fatalf("expected EditMessage to be called once, got %d", editCount)
}
if editedMessageID != "msg-1" {
t.Fatalf("expected EditMessage target msg-1, got %s", editedMessageID)
}
}
func TestHandleTaskStatusSend_UsesDraftSender(t *testing.T) {
m := newTestManager()
var draftCalled bool
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
draftCalled = true
return nil
},
editFn: func(_ context.Context, _, _, _ string) error {
t.Fatal("EditMessage should not be called when draft succeeds")
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when draft succeeds")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task progress 50%",
IsTaskStatus: true,
TaskID: "task-draft",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !draftCalled {
t.Fatal("expected SendDraft to be called for task status")
}
}
func TestHandleTaskStatusSend_Final_UpdatesDraftInPlace(t *testing.T) {
m := newTestManager()
var draftUpdateCalled bool
var draftUpdateDraftID int
var draftUpdateContent string
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error {
t.Fatal("Send should not be called when draft update succeeds")
return nil
},
},
draftFn: func(_ context.Context, chatID string, draftID int, content string) error {
draftUpdateCalled = true
draftUpdateDraftID = draftID
draftUpdateContent = content
if chatID != "123" {
t.Fatalf("expected chatID 123, got %s", chatID)
}
return nil
},
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) {
t.Fatal("SendWithID should not be called when draft update succeeds")
return "", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
m.taskMsgIDs.Store(taskStatusKey("test", "123", "task-final"), statusMsgEntry{draftID: 42, createdAt: time.Now()})
m.statusEditTimes.Store(taskStatusKey("test", "123", "task-final"), time.Now())
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "123",
Content: "task completed",
IsTaskStatus: true,
TaskID: "task-final",
Final: true,
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if !draftUpdateCalled {
t.Fatal("expected SendDraft to update draft with final content")
}
if draftUpdateDraftID != 42 {
t.Fatalf("expected draftID 42, got %d", draftUpdateDraftID)
}
if draftUpdateContent != "task completed" {
t.Fatalf("expected draft content 'task completed', got %q", draftUpdateContent)
}
if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "123", "task-final")); loaded {
t.Fatal("expected taskMsgIDs entry to be deleted for final task status")
}
if _, loaded := m.statusEditTimes.Load(taskStatusKey("test", "123", "task-final")); loaded {
t.Fatal("expected statusEditTimes entry to be deleted for final task status")
}
}
func TestHandleTaskStatusSend_DraftStreaming_IsolatedByChatThread(t *testing.T) {
m := newTestManager()
type draftCall struct {
chatID string
draftID int
content string
}
calls := make([]draftCall, 0, 2)
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, chatID string, draftID int, content string) error {
calls = append(calls, draftCall{chatID: chatID, draftID: draftID, content: content})
return nil
},
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil },
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msgA := bus.OutboundMessage{
Channel: "test",
ChatID: "-100/10",
Content: "A:10%",
IsTaskStatus: true,
TaskID: "shared-task",
}
msgB := bus.OutboundMessage{
Channel: "test",
ChatID: "-100/20",
Content: "B:10%",
IsTaskStatus: true,
TaskID: "shared-task",
}
m.handleTaskStatusSend(context.Background(), "test", w, msgA)
m.handleTaskStatusSend(context.Background(), "test", w, msgB)
if len(calls) != 2 {
t.Fatalf("expected 2 SendDraft calls, got %d", len(calls))
}
if calls[0].chatID == calls[1].chatID {
t.Fatalf("expected different chat threads, got %q and %q", calls[0].chatID, calls[1].chatID)
}
if calls[0].draftID == calls[1].draftID {
t.Fatalf("expected distinct draft IDs per thread key, both got %d", calls[0].draftID)
}
if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/10", "shared-task")); !loaded {
t.Fatal("expected taskMsgIDs entry for thread A")
}
if _, loaded := m.taskMsgIDs.Load(taskStatusKey("test", "-100/20", "shared-task")); !loaded {
t.Fatal("expected taskMsgIDs entry for thread B")
}
}
func TestHandleTaskStatusSend_DraftFailure_DoesNotClobberTrackedMessageID(t *testing.T) {
m := newTestManager()
var sendWithIDCount int
var editCount int
var editedMessageID string
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error {
return fmt.Errorf("draft unsupported")
},
editFn: func(_ context.Context, _, messageID, _ string) error {
editCount++
editedMessageID = messageID
return nil
},
sendWithID: func(_ context.Context, _, _ string) (string, error) {
sendWithIDCount++
return "task-msg-1", nil
},
}
w := &channelWorker{ch: ch, limiter: rate.NewLimiter(rate.Inf, 1)}
msg := bus.OutboundMessage{
Channel: "test",
ChatID: "group-main",
Content: "task-10%",
IsTaskStatus: true,
TaskID: "task-1",
}
m.handleTaskStatusSend(context.Background(), "test", w, msg)
msg.Content = "task-20%"
m.handleTaskStatusSend(context.Background(), "test", w, msg)
if sendWithIDCount != 1 {
t.Fatalf("expected SendWithID to be called once, got %d", sendWithIDCount)
}
if editCount != 1 {
t.Fatalf("expected EditMessage to be called once, got %d", editCount)
}
if editedMessageID != "task-msg-1" {
t.Fatalf("expected EditMessage target task-msg-1, got %s", editedMessageID)
}
}
func TestPreSend_ClearsDraftState(t *testing.T) {
m := newTestManager()
ch := &mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
}
m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
edited := m.preSend(context.Background(), "test", msg, ch)
if edited {
t.Fatal("expected preSend to return false for draft-based status (sendMessage replaces draft)")
}
if _, loaded := m.statusMsgIDs.Load("test:123"); loaded {
t.Fatal("expected draft status entry to be deleted after preSend")
}
}
func TestGenerateDraftID_Stable(t *testing.T) {
id1 := generateDraftID("telegram:123")
id2 := generateDraftID("telegram:123")
if id1 != id2 {
t.Fatalf("expected stable draft ID, got %d vs %d", id1, id2)
}
if id1 == 0 {
t.Fatal("expected non-zero draft ID")
}
id3 := generateDraftID("telegram:456")
if id1 == id3 {
t.Fatalf("expected different draft IDs for different keys, both got %d", id1)
}
}
// TestPreSend_DismissesDraftBeforeSend verifies that preSend explicitly
// dismisses a draft-based status bubble (via SendDraft with empty text)
// before proceeding to send the permanent message. This prevents ghost
// draft bubbles when a user message arrives between the last draft update
// and the final sendMessage.
// TestPreSend_DismissesDraftBeforeSend verifies that preSend explicitly
// dismisses a draft-based status bubble (via SendDraft with empty text)
// before proceeding to send the permanent message. This prevents ghost
// draft bubbles when a user message arrives between the last draft update
// and the final sendMessage.
func TestPreSend_DismissesDraftBeforeSend(t *testing.T) {
m := newTestManager()
var dismissCalled bool
var dismissContent string
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, content string) error {
dismissCalled = true
dismissContent = content
return nil
},
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil },
}
m.statusMsgIDs.Store("test:123", statusMsgEntry{draftID: 42, createdAt: time.Now()})
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final response"}
edited := m.preSend(context.Background(), "test", msg, ch)
if edited {
t.Fatal("expected preSend to return false for draft-based status")
}
if !dismissCalled {
t.Fatal("expected preSend to call SendDraft to dismiss the draft")
}
if dismissContent != "" {
t.Fatalf("expected empty dismiss content, got %q", dismissContent)
}
}
// TestRecordTypingStop_CleansUpOldEntry verifies that recording a new
// typing stop function calls the previous stop first.
// TestRecordTypingStop_CleansUpOldEntry verifies that recording a new
// typing stop function calls the previous stop first.
func TestRecordTypingStop_CleansUpOldEntry(t *testing.T) {
m := newTestManager()
var oldStopped atomic.Bool
m.RecordTypingStop("tg", "42", func() { oldStopped.Store(true) })
m.RecordTypingStop("tg", "42", func() {})
if !oldStopped.Load() {
t.Fatal("expected old typing stop to be called when new entry is recorded")
}
}
// TestRecordReactionUndo_CleansUpOldEntry verifies that recording a new
// reaction undo function calls the previous undo first.
// TestRecordReactionUndo_CleansUpOldEntry verifies that recording a new
// reaction undo function calls the previous undo first.
func TestRecordReactionUndo_CleansUpOldEntry(t *testing.T) {
m := newTestManager()
var oldUndone atomic.Bool
m.RecordReactionUndo("tg", "42", func() { oldUndone.Store(true) })
m.RecordReactionUndo("tg", "42", func() {})
if !oldUndone.Load() {
t.Fatal("expected old reaction undo to be called when new entry is recorded")
}
}
// TestPreSend_DraftDismiss_ClearsEditTimes verifies that dismissing a draft
// in preSend also clears the statusEditTimes entry for that key, preventing
// stale throttle state from affecting the next processing cycle.
// TestPreSend_DraftDismiss_ClearsEditTimes verifies that dismissing a draft
// in preSend also clears the statusEditTimes entry for that key, preventing
// stale throttle state from affecting the next processing cycle.
func TestPreSend_DraftDismiss_ClearsEditTimes(t *testing.T) {
m := newTestManager()
ch := &mockDraftSender{
mockChannel: mockChannel{
sendFn: func(_ context.Context, _ bus.OutboundMessage) error { return nil },
},
draftFn: func(_ context.Context, _ string, _ int, _ string) error { return nil },
editFn: func(_ context.Context, _, _, _ string) error { return nil },
sendWithID: func(_ context.Context, _, _ string) (string, error) { return "", nil },
}
key := "test:123"
m.statusMsgIDs.Store(key, statusMsgEntry{draftID: 42, createdAt: time.Now()})
m.statusEditTimes.Store(key, time.Now())
msg := bus.OutboundMessage{Channel: "test", ChatID: "123", Content: "final"}
m.preSend(context.Background(), "test", msg, ch)
if _, loaded := m.statusEditTimes.Load(key); loaded {
t.Fatal("expected statusEditTimes to be cleared after draft dismiss")
}
}

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"html"
"io"
"mime"
"net/url"
"os"
@ -13,6 +14,9 @@ import (
"sync"
"time"
"github.com/gomarkdown/markdown"
mdhtml "github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@ -268,6 +272,12 @@ func (c *MatrixChannel) Stop(ctx context.Context) error {
return nil
}
func markdownToHTML(md string) string {
p := parser.NewWithExtensions(parser.CommonExtensions | parser.AutoHeadingIDs)
renderer := mdhtml.NewRenderer(mdhtml.RendererOptions{Flags: mdhtml.CommonFlags})
return strings.TrimSpace(string(markdown.ToHTML([]byte(md), p, renderer)))
}
func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
@ -283,16 +293,22 @@ func (c *MatrixChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return nil
}
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, &event.MessageEventContent{
MsgType: event.MsgText,
Body: content,
})
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, c.messageContent(content))
if err != nil {
return fmt.Errorf("matrix send: %w", channels.ErrTemporary)
}
return nil
}
func (c *MatrixChannel) messageContent(text string) *event.MessageEventContent {
mc := &event.MessageEventContent{MsgType: event.MsgText, Body: text}
if c.config.MessageFormat != "plain" {
mc.Format = event.FormatHTML
mc.FormattedBody = markdownToHTML(text)
}
return mc
}
// SendMedia implements channels.MediaSender.
func (c *MatrixChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
@ -482,10 +498,7 @@ func (c *MatrixChannel) EditMessage(ctx context.Context, chatID string, messageI
return fmt.Errorf("matrix message ID is empty")
}
editContent := &event.MessageEventContent{
MsgType: event.MsgText,
Body: content,
}
editContent := c.messageContent(content)
editContent.SetEdit(id.EventID(messageID))
_, err := c.client.SendMessageEvent(ctx, roomID, event.EventMessage, editContent)
@ -714,17 +727,23 @@ func (c *MatrixChannel) downloadMedia(
reqCtx, cancel := context.WithTimeout(dlCtx, 20*time.Second)
defer cancel()
data, err := c.client.DownloadBytes(reqCtx, parsed)
resp, err := c.client.Download(reqCtx, parsed)
if err != nil {
return "", err
}
defer resp.Body.Close()
reader := resp.Body
readerClose := func() error { return nil }
// Encrypted attachments put URL in msgEvt.File and require client-side decryption.
if msgEvt != nil && msgEvt.File != nil && msgEvt.URL == "" {
err = msgEvt.File.DecryptInPlace(data)
if err != nil {
if err = msgEvt.File.PrepareForDecryption(); err != nil {
return "", fmt.Errorf("decrypt matrix media: %w", err)
}
decryptReader := msgEvt.File.DecryptStream(resp.Body)
reader = decryptReader
readerClose = decryptReader.Close
}
label := matrixMediaLabel(msgEvt, mediaKind)
@ -737,14 +756,28 @@ func (c *MatrixChannel) downloadMedia(
if err != nil {
return "", err
}
defer tmp.Close()
tmpPath := tmp.Name()
cleanup := true
defer func() {
_ = tmp.Close()
if cleanup {
_ = os.Remove(tmpPath)
}
}()
if _, err = tmp.Write(data); err != nil {
_ = os.Remove(tmp.Name())
_, err = io.Copy(tmp, reader)
if err != nil {
return "", err
}
if err = readerClose(); err != nil {
return "", fmt.Errorf("decrypt matrix media: %w", err)
}
if err = tmp.Close(); err != nil {
return "", err
}
return tmp.Name(), nil
cleanup = false
return tmpPath, nil
}
func matrixContentType(msgEvt *event.MessageEventContent) string {

View file

@ -2,14 +2,19 @@ package matrix
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
"github.com/sipeed/picoclaw/pkg/config"
)
func TestMatrixLocalpartMentionRegexp(t *testing.T) {
@ -194,6 +199,50 @@ func TestMatrixMediaExt(t *testing.T) {
}
}
func TestDownloadMedia_WritesResponseToTempFile(t *testing.T) {
const wantBody = "matrix-media-payload"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/_matrix/client/v1/media/download/matrix.test/abc123") {
t.Fatalf("unexpected download path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "image/png")
_, _ = w.Write([]byte(wantBody))
}))
defer server.Close()
client, err := mautrix.NewClient(server.URL, id.UserID("@picoclaw:matrix.test"), "")
if err != nil {
t.Fatalf("NewClient: %v", err)
}
ch := &MatrixChannel{client: client}
msg := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: "image.png",
URL: id.ContentURIString("mxc://matrix.test/abc123"),
Info: &event.FileInfo{MimeType: "image/png"},
}
path, err := ch.downloadMedia(context.Background(), msg, "image")
if err != nil {
t.Fatalf("downloadMedia: %v", err)
}
defer os.Remove(path)
if ext := filepath.Ext(path); ext != ".png" {
t.Fatalf("temp file extension=%q want=.png", ext)
}
got, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if string(got) != wantBody {
t.Fatalf("file contents=%q want=%q", string(got), wantBody)
}
}
func TestExtractInboundContent_ImageNoURLFallback(t *testing.T) {
ch := &MatrixChannel{}
msg := &event.MessageEventContent{
@ -289,3 +338,50 @@ func TestMatrixOutboundContent(t *testing.T) {
t.Fatalf("unexpected fallback body: %q", noCaption.Body)
}
}
func TestMarkdownToHTML(t *testing.T) {
tests := []struct {
name string
input string
contains string
}{
{"bold", "**hello**", "<strong>hello</strong>"},
{"italic", "_world_", "<em>world</em>"},
{"header", "### Title", "<h3"},
{"code block", "```\nfoo()\n```", "<code>"},
{"inline code", "`x`", "<code>x</code>"},
{"plain text", "just text", "just text"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := markdownToHTML(tt.input)
if !strings.Contains(got, tt.contains) {
t.Fatalf("markdownToHTML(%q) = %q, want it to contain %q", tt.input, got, tt.contains)
}
})
}
}
func TestMessageContent(t *testing.T) {
richtext := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "richtext"}}
plain := &MatrixChannel{config: config.MatrixConfig{MessageFormat: "plain"}}
defaultt := &MatrixChannel{config: config.MatrixConfig{}}
for _, c := range []*MatrixChannel{richtext, defaultt} {
mc := c.messageContent("**hi**")
if mc.Format != event.FormatHTML {
t.Errorf("format %q: expected FormatHTML, got %q", c.config.MessageFormat, mc.Format)
}
if !strings.Contains(mc.FormattedBody, "<strong>hi</strong>") {
t.Errorf("format %q: FormattedBody %q missing <strong>", c.config.MessageFormat, mc.FormattedBody)
}
if mc.Body != "**hi**" {
t.Errorf("format %q: Body should remain plain, got %q", c.config.MessageFormat, mc.Body)
}
}
mc := plain.messageContent("**hi**")
if mc.Format != "" || mc.FormattedBody != "" {
t.Errorf("plain: expected no formatting, got format=%q formattedBody=%q", mc.Format, mc.FormattedBody)
}
}

View file

@ -150,26 +150,6 @@ func (c *PicoChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return c.broadcastToSession(msg.ChatID, outMsg)
}
// SendWithID implements channels.MessageSenderWithID.
// It sends a message and returns a generated message ID.
func (c *PicoChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
if !c.IsRunning() {
return "", channels.ErrNotRunning
}
msgID := uuid.New().String()
outMsg := newMessage(TypeMessageCreate, map[string]any{
"content": content,
"message_id": msgID,
})
if err := c.broadcastToSession(chatID, outMsg); err != nil {
return "", err
}
return msgID, nil
}
// EditMessage implements channels.MessageEditor.
func (c *PicoChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
outMsg := newMessage(TypeMessageUpdate, map[string]any{

View file

@ -3,7 +3,10 @@ package qq
import (
"context"
"fmt"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/tencent-connect/botgo"
@ -20,6 +23,14 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
const (
dedupTTL = 5 * time.Minute
dedupInterval = 60 * time.Second
dedupMaxSize = 10000 // hard cap on dedup map entries
typingResend = 8 * time.Second
typingSeconds = 10
)
type QQChannel struct {
*channels.BaseChannel
config config.QQConfig
@ -28,20 +39,37 @@ type QQChannel struct {
ctx context.Context
cancel context.CancelFunc
sessionManager botgo.SessionManager
processedIDs map[string]bool
mu sync.RWMutex
// Chat routing: track whether a chatID is group or direct.
chatType sync.Map // chatID → "group" | "direct"
// Passive reply: store last inbound message ID per chat.
lastMsgID sync.Map // chatID → string
// msg_seq: per-chat atomic counter for multi-part replies.
msgSeqCounters sync.Map // chatID → *atomic.Uint64
// Time-based dedup replacing the unbounded map.
dedup map[string]time.Time
muDedup sync.Mutex
// done is closed on Stop to shut down the dedup janitor.
done chan struct{}
stopOnce sync.Once
}
func NewQQChannel(cfg config.QQConfig, messageBus *bus.MessageBus) (*QQChannel, error) {
base := channels.NewBaseChannel("qq", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(cfg.MaxMessageLength),
channels.WithGroupTrigger(cfg.GroupTrigger),
channels.WithReasoningChannelID(cfg.ReasoningChannelID),
)
return &QQChannel{
BaseChannel: base,
config: cfg,
processedIDs: make(map[string]bool),
BaseChannel: base,
config: cfg,
dedup: make(map[string]time.Time),
done: make(chan struct{}),
}, nil
}
@ -50,8 +78,13 @@ func (c *QQChannel) Start(ctx context.Context) error {
return fmt.Errorf("QQ app_id and app_secret not configured")
}
botgo.SetLogger(logger.NewLogger("botgo"))
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
// Reinitialize shutdown signal for clean restart.
c.done = make(chan struct{})
c.stopOnce = sync.Once{}
// create token source
credentials := &token.QQBotCredentials{
AppID: c.config.AppID,
@ -99,6 +132,15 @@ func (c *QQChannel) Start(ctx context.Context) error {
}
}()
// start dedup janitor goroutine
go c.dedupJanitor()
// Pre-register reasoning_channel_id as group chat if configured,
// so outbound-only destinations are routed correctly.
if c.config.ReasoningChannelID != "" {
c.chatType.Store(c.config.ReasoningChannelID, "group")
}
c.SetRunning(true)
logger.InfoC("qq", "QQ bot started successfully")
@ -109,6 +151,9 @@ func (c *QQChannel) Stop(ctx context.Context) error {
logger.InfoC("qq", "Stopping QQ bot")
c.SetRunning(false)
// Signal the dedup janitor to stop (idempotent).
c.stopOnce.Do(func() { close(c.done) })
if c.cancel != nil {
c.cancel()
}
@ -116,21 +161,82 @@ func (c *QQChannel) Stop(ctx context.Context) error {
return nil
}
// getChatKind returns the chat type for a given chatID ("group" or "direct").
// Unknown chatIDs default to "group" and log a warning, since QQ group IDs are
// more common as outbound-only destinations (e.g. reasoning_channel_id).
func (c *QQChannel) getChatKind(chatID string) string {
if v, ok := c.chatType.Load(chatID); ok {
if k, ok := v.(string); ok {
return k
}
}
logger.DebugCF("qq", "Unknown chat type for chatID, defaulting to group", map[string]any{
"chat_id": chatID,
})
return "group"
}
func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
// construct message
chatKind := c.getChatKind(msg.ChatID)
// Build message with content.
msgToCreate := &dto.MessageToCreate{
Content: msg.Content,
MsgType: dto.TextMsg,
}
// Use Markdown message type if enabled in config.
if c.config.SendMarkdown {
msgToCreate.MsgType = dto.MarkdownMsg
msgToCreate.Markdown = &dto.Markdown{
Content: msg.Content,
}
// Clear plain content to avoid sending duplicate text.
msgToCreate.Content = ""
}
// Attach passive reply msg_id and msg_seq if available.
if v, ok := c.lastMsgID.Load(msg.ChatID); ok {
if msgID, ok := v.(string); ok && msgID != "" {
msgToCreate.MsgID = msgID
// Increment msg_seq atomically for multi-part replies.
if counterVal, ok := c.msgSeqCounters.Load(msg.ChatID); ok {
if counter, ok := counterVal.(*atomic.Uint64); ok {
seq := counter.Add(1)
msgToCreate.MsgSeq = uint32(seq)
}
}
}
}
// Sanitize URLs in group messages to avoid QQ's URL blacklist rejection.
if chatKind == "group" {
if msgToCreate.Content != "" {
msgToCreate.Content = sanitizeURLs(msgToCreate.Content)
}
if msgToCreate.Markdown != nil && msgToCreate.Markdown.Content != "" {
msgToCreate.Markdown.Content = sanitizeURLs(msgToCreate.Markdown.Content)
}
}
// Route to group or C2C.
var err error
if chatKind == "group" {
_, err = c.api.PostGroupMessage(ctx, msg.ChatID, msgToCreate)
} else {
_, err = c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
}
// send C2C message
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
if err != nil {
logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
"error": err.Error(),
logger.ErrorCF("qq", "Failed to send message", map[string]any{
"chat_id": msg.ChatID,
"chat_kind": chatKind,
"error": err.Error(),
})
return fmt.Errorf("qq send: %w", channels.ErrTemporary)
}
@ -138,7 +244,150 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return nil
}
// handleC2CMessage handles QQ private messages
// StartTyping implements channels.TypingCapable.
// It sends an InputNotify (msg_type=6) immediately and re-sends every 8 seconds.
// The returned stop function is idempotent and cancels the goroutine.
func (c *QQChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
// We need a stored msg_id for passive InputNotify; skip if none available.
v, ok := c.lastMsgID.Load(chatID)
if !ok {
return func() {}, nil
}
msgID, ok := v.(string)
if !ok || msgID == "" {
return func() {}, nil
}
chatKind := c.getChatKind(chatID)
sendTyping := func(sendCtx context.Context) {
typingMsg := &dto.MessageToCreate{
MsgType: dto.InputNotifyMsg,
MsgID: msgID,
InputNotify: &dto.InputNotify{
InputType: 1,
InputSecond: typingSeconds,
},
}
var err error
if chatKind == "group" {
_, err = c.api.PostGroupMessage(sendCtx, chatID, typingMsg)
} else {
_, err = c.api.PostC2CMessage(sendCtx, chatID, typingMsg)
}
if err != nil {
logger.DebugCF("qq", "Failed to send typing indicator", map[string]any{
"chat_id": chatID,
"error": err.Error(),
})
}
}
// Send immediately.
sendTyping(c.ctx)
typingCtx, cancel := context.WithCancel(c.ctx)
go func() {
ticker := time.NewTicker(typingResend)
defer ticker.Stop()
for {
select {
case <-typingCtx.Done():
return
case <-ticker.C:
sendTyping(typingCtx)
}
}
}()
return cancel, nil
}
// SendMedia implements the channels.MediaSender interface.
// QQ RichMediaMessage requires an HTTP/HTTPS URL — local file paths are not supported.
// If part.Ref is already an http(s) URL it is used directly; otherwise we try
// the media store, and skip with a warning if the resolved path is not an HTTP URL.
func (c *QQChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
chatKind := c.getChatKind(msg.ChatID)
for _, part := range msg.Parts {
// If the ref is already an HTTP(S) URL, use it directly.
mediaURL := part.Ref
if !isHTTPURL(mediaURL) {
// Try resolving through media store.
store := c.GetMediaStore()
if store == nil {
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, no media store available", map[string]any{
"ref": part.Ref,
})
continue
}
resolved, err := store.Resolve(part.Ref)
if err != nil {
logger.ErrorCF("qq", "Failed to resolve media ref", map[string]any{
"ref": part.Ref,
"error": err.Error(),
})
continue
}
if !isHTTPURL(resolved) {
logger.WarnCF("qq", "QQ media requires HTTP/HTTPS URL, local files not supported", map[string]any{
"ref": part.Ref,
"resolved": resolved,
})
continue
}
mediaURL = resolved
}
// Map part type to QQ file type: 1=image, 2=video, 3=audio, 4=file.
var fileType uint64
switch part.Type {
case "image":
fileType = 1
case "video":
fileType = 2
case "audio":
fileType = 3
default:
fileType = 4 // file
}
richMedia := &dto.RichMediaMessage{
FileType: fileType,
URL: mediaURL,
SrvSendMsg: true,
}
var sendErr error
if chatKind == "group" {
_, sendErr = c.api.PostGroupMessage(ctx, msg.ChatID, richMedia)
} else {
_, sendErr = c.api.PostC2CMessage(ctx, msg.ChatID, richMedia)
}
if sendErr != nil {
logger.ErrorCF("qq", "Failed to send media", map[string]any{
"type": part.Type,
"chat_id": msg.ChatID,
"error": sendErr.Error(),
})
return fmt.Errorf("qq send media: %w", channels.ErrTemporary)
}
}
return nil
}
// handleC2CMessage handles QQ private messages.
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
// deduplication check
@ -167,7 +416,13 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
"length": len(content),
})
// 转发到消息总线
// Store chat routing context.
c.chatType.Store(senderID, "direct")
c.lastMsgID.Store(senderID, data.ID)
// Reset msg_seq counter for new inbound message.
c.msgSeqCounters.Store(senderID, new(atomic.Uint64))
metadata := map[string]string{}
sender := bus.SenderInfo{
@ -195,7 +450,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
}
}
// handleGroupATMessage handles QQ group @ messages
// handleGroupATMessage handles QQ group @ messages.
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
// deduplication check
@ -232,7 +487,13 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
"length": len(content),
})
// 转发到消息总线(使用 GroupID 作为 ChatID
// Store chat routing context using GroupID as chatID.
c.chatType.Store(data.GroupID, "group")
c.lastMsgID.Store(data.GroupID, data.ID)
// Reset msg_seq counter for new inbound message.
c.msgSeqCounters.Store(data.GroupID, new(atomic.Uint64))
metadata := map[string]string{
"group_id": data.GroupID,
}
@ -262,29 +523,102 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
}
}
// isDuplicate 检查消息是否重复
// isDuplicate checks whether a message has been seen within the TTL window.
// It also enforces a hard cap on map size by evicting oldest entries.
func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock()
defer c.mu.Unlock()
c.muDedup.Lock()
defer c.muDedup.Unlock()
if c.processedIDs[messageID] {
if ts, exists := c.dedup[messageID]; exists && time.Since(ts) < dedupTTL {
return true
}
c.processedIDs[messageID] = true
// 简单清理:限制 map 大小
if len(c.processedIDs) > 10000 {
// 清空一半
count := 0
for id := range c.processedIDs {
if count >= 5000 {
break
// Enforce hard cap: evict oldest entries when at capacity.
if len(c.dedup) >= dedupMaxSize {
var oldestID string
var oldestTS time.Time
for id, ts := range c.dedup {
if oldestID == "" || ts.Before(oldestTS) {
oldestID = id
oldestTS = ts
}
delete(c.processedIDs, id)
count++
}
if oldestID != "" {
delete(c.dedup, oldestID)
}
}
c.dedup[messageID] = time.Now()
return false
}
// dedupJanitor periodically evicts expired entries from the dedup map.
func (c *QQChannel) dedupJanitor() {
ticker := time.NewTicker(dedupInterval)
defer ticker.Stop()
for {
select {
case <-c.done:
return
case <-ticker.C:
// Collect expired keys under read-like scan.
c.muDedup.Lock()
now := time.Now()
var expired []string
for id, ts := range c.dedup {
if now.Sub(ts) >= dedupTTL {
expired = append(expired, id)
}
}
for _, id := range expired {
delete(c.dedup, id)
}
c.muDedup.Unlock()
}
}
}
// isHTTPURL returns true if s starts with http:// or https://.
func isHTTPURL(s string) bool {
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
// urlPattern matches URLs with explicit http(s):// scheme.
// Only scheme-prefixed URLs are matched to avoid false positives on bare text
// like version numbers (e.g., "1.2.3") or domain-like fragments.
var urlPattern = regexp.MustCompile(
`(?i)` +
`https?://` + // required scheme
`(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+` + // domain parts
`[a-zA-Z]{2,}` + // TLD
`(?:[/?#]\S*)?`, // optional path/query/fragment
)
// sanitizeURLs replaces dots in URL domains with "。" (fullwidth period)
// to prevent QQ's URL blacklist from rejecting the message.
func sanitizeURLs(text string) string {
return urlPattern.ReplaceAllStringFunc(text, func(match string) string {
// Split into scheme + rest (scheme is always present).
idx := strings.Index(match, "://")
scheme := match[:idx+3]
rest := match[idx+3:]
// Find where the domain ends (first / ? or #).
domainEnd := len(rest)
for i, ch := range rest {
if ch == '/' || ch == '?' || ch == '#' {
domainEnd = i
break
}
}
domain := rest[:domainEnd]
path := rest[domainEnd:]
// Replace dots in domain only.
domain = strings.ReplaceAll(domain, ".", "。")
return scheme + domain + path
})
}

View file

@ -122,7 +122,11 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
slack.MsgOptionText(msg.Content, false),
}
if threadTS != "" {
if msg.ReplyToMessageID != "" && threadTS == "" {
// Answer to the message by creating a Thread under it
opts = append(opts, slack.MsgOptionTS(msg.ReplyToMessageID))
} else if threadTS != "" {
// If we are already in a thread, continue in the thread
opts = append(opts, slack.MsgOptionTS(threadTS))
}
@ -183,7 +187,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
title = filename
}
_, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{
_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
Channel: channelID,
File: localPath,
Filename: filename,
@ -303,17 +307,16 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
Timestamp: messageTS,
})
var contentBuf strings.Builder
contentBuf.WriteString(c.stripBotMention(ev.Text))
content := ev.Text
content = c.stripBotMention(content)
// In non-DM channels, apply group trigger filtering
if !strings.HasPrefix(channelID, "D") {
respond, cleaned := c.ShouldRespondInGroup(false, contentBuf.String())
respond, cleaned := c.ShouldRespondInGroup(false, content)
if !respond {
return
}
contentBuf.Reset()
contentBuf.WriteString(cleaned)
content = cleaned
}
var mediaPaths []string
@ -341,11 +344,10 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
continue
}
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
fmt.Fprintf(&contentBuf, "\n[file: %s]", file.Name)
content += fmt.Sprintf("\n[file: %s]", file.Name)
}
}
content := contentBuf.String()
if strings.TrimSpace(content) == "" {
return
}

View file

@ -77,6 +77,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
if baseURL := strings.TrimRight(strings.TrimSpace(telegramCfg.BaseURL), "/"); baseURL != "" {
opts = append(opts, telego.WithAPIServer(baseURL))
}
opts = append(opts, telego.WithLogger(logger.NewLogger("telego")))
bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil {
@ -168,7 +169,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return channels.ErrNotRunning
}
chatID, threadID, err := parseChatID(msg.ChatID)
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
@ -180,6 +181,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// The Manager already splits messages to ≤4000 chars (WithMaxMessageLength),
// so msg.Content is guaranteed to be within that limit. We still need to
// check if HTML expansion pushes it beyond Telegram's 4096-char API limit.
replyToID := msg.ReplyToMessageID
queue := []string{msg.Content}
for len(queue) > 0 {
chunk := queue[0]
@ -200,9 +202,11 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
continue
}
if err := c.sendHTMLChunk(ctx, chatID, htmlContent, chunk); err != nil {
if err := c.sendHTMLChunk(ctx, chatID, threadID, htmlContent, chunk, replyToID); err != nil {
return err
}
// Only the first chunk should be a reply; subsequent chunks are normal messages.
replyToID = ""
}
return nil
@ -210,11 +214,19 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// sendHTMLChunk sends a single HTML message, falling back to the original
// markdown as plain text on parse failure so users never see raw HTML tags.
func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent, mdFallback string) error {
func (c *TelegramChannel) sendHTMLChunk(
ctx context.Context, chatID int64, threadID int, htmlContent, mdFallback string, replyToID string,
) error {
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
if threadID != 0 {
tgMsg.MessageThreadID = threadID
tgMsg.MessageThreadID = threadID
if replyToID != "" {
if mid, parseErr := strconv.Atoi(replyToID); parseErr == nil {
tgMsg.ReplyParameters = &telego.ReplyParameters{
MessageID: mid,
}
}
}
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
@ -230,54 +242,21 @@ func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlC
return nil
}
// SendWithID implements channels.MessageSenderWithID.
// It sends a message and returns the platform message ID.
func (c *TelegramChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
if !c.IsRunning() {
return "", channels.ErrNotRunning
}
cid, tid, err := parseChatID(chatID)
if err != nil {
return "", fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
}
htmlContent := markdownToTelegramHTML(content)
tgMsg := tu.Message(tu.ID(cid), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
if tid != 0 {
tgMsg.MessageThreadID = tid
}
sent, err := c.bot.SendMessage(ctx, tgMsg)
if err != nil {
// Fallback to plain text
tgMsg.ParseMode = ""
sent, err = c.bot.SendMessage(ctx, tgMsg)
if err != nil {
return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
return fmt.Sprintf("%d", sent.MessageID), nil
}
// StartTyping implements channels.TypingCapable.
// It sends ChatAction(typing) immediately and then repeats every 4 seconds
// (Telegram's typing indicator expires after ~5s) in a background goroutine.
// The returned stop function is idempotent and cancels the goroutine.
func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
cid, tid, err := parseChatID(chatID)
cid, threadID, err := parseTelegramChatID(chatID)
if err != nil {
return func() {}, err
}
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
action.MessageThreadID = threadID
// Send the first typing action immediately
firstAction := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
if tid != 0 {
firstAction.MessageThreadID = tid
}
_ = c.bot.SendChatAction(ctx, firstAction)
_ = c.bot.SendChatAction(ctx, action)
typingCtx, cancel := context.WithCancel(ctx)
go func() {
@ -288,11 +267,9 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
case <-typingCtx.Done():
return
case <-ticker.C:
action := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
if tid != 0 {
action.MessageThreadID = tid
}
_ = c.bot.SendChatAction(typingCtx, action)
a := tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)
a.MessageThreadID = threadID
_ = c.bot.SendChatAction(typingCtx, a)
}
}
}()
@ -302,7 +279,7 @@ func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(
// EditMessage implements channels.MessageEditor.
func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
cid, _, err := parseChatID(chatID)
cid, _, err := parseTelegramChatID(chatID)
if err != nil {
return err
}
@ -331,16 +308,14 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
text = "Thinking... 💭"
}
cid, tid, err := parseChatID(chatID)
cid, threadID, err := parseTelegramChatID(chatID)
if err != nil {
return "", err
}
params := tu.Message(tu.ID(cid), text)
if tid != 0 {
params.MessageThreadID = tid
}
pMsg, err := c.bot.SendMessage(ctx, params)
phMsg := tu.Message(tu.ID(cid), text)
phMsg.MessageThreadID = threadID
pMsg, err := c.bot.SendMessage(ctx, phMsg)
if err != nil {
return "", err
}
@ -348,44 +323,13 @@ func (c *TelegramChannel) SendPlaceholder(ctx context.Context, chatID string) (s
return fmt.Sprintf("%d", pMsg.MessageID), nil
}
// SendDraft implements channels.DraftSender.
// It uses Telegram Bot API's sendMessageDraft for progressive message streaming
// without the "edited" indicator. In groups, draft is used for dedicated topics only.
func (c *TelegramChannel) SendDraft(ctx context.Context, chatID string, draftID int, content string) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
cid, tid, err := parseChatID(chatID)
if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
}
if !isLikelyPrivateChatID(cid) && tid == 0 {
return fmt.Errorf("telegram draft unsupported for non-threaded group chat: %w", channels.ErrSendFailed)
}
htmlContent := markdownToTelegramHTML(content)
params := &telego.SendMessageDraftParams{
ChatID: cid,
MessageThreadID: tid,
DraftID: draftID,
Text: htmlContent,
ParseMode: telego.ModeHTML,
}
if err = c.bot.SendMessageDraft(ctx, params); err != nil {
// HTML parse failure — retry as plain text
params.ParseMode = ""
params.Text = content
return c.bot.SendMessageDraft(ctx, params)
}
return nil
}
// SendMedia implements the channels.MediaSender interface.
func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
chatID, threadID, err := parseChatID(msg.ChatID)
chatID, threadID, err := parseTelegramChatID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}
@ -492,12 +436,11 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
chatID := message.Chat.ID
c.chatIDs[platformID] = chatID
threadID := message.MessageThreadID
content := ""
mediaPaths := []string{}
chatIDStr := formatChatID(chatID, threadID)
chatIDStr := fmt.Sprintf("%d", chatID)
messageIDStr := fmt.Sprintf("%d", message.MessageID)
scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr)
@ -589,21 +532,28 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
content = cleaned
}
logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": sender.CanonicalID,
"chat_id": fmt.Sprintf("%d", chatID),
"thread_id": threadID,
"chat_route": chatIDStr,
"preview": utils.Truncate(content, 50),
})
// For forum topics, embed the thread ID as "chatID/threadID" so replies
// route to the correct topic and each topic gets its own session.
// Only forum groups (IsForum) are handled; regular group reply threads
// must share one session per group.
compositeChatID := fmt.Sprintf("%d", chatID)
threadID := message.MessageThreadID
if message.Chat.IsForum && threadID != 0 {
compositeChatID = fmt.Sprintf("%d/%d", chatID, threadID)
}
// Placeholder is now auto-triggered by BaseChannel.HandleMessage via PlaceholderCapable
logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": sender.CanonicalID,
"chat_id": compositeChatID,
"thread_id": threadID,
"preview": utils.Truncate(content, 50),
})
peerKind := "direct"
peerID := fmt.Sprintf("%d", user.ID)
if message.Chat.Type != "private" {
peerKind = "group"
peerID = fmt.Sprintf("%d", chatID)
peerID = compositeChatID
}
peer := bus.Peer{Kind: peerKind, ID: peerID}
@ -616,11 +566,17 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
// Set parent_peer metadata for per-topic agent binding.
if message.Chat.IsForum && threadID != 0 {
metadata["parent_peer_kind"] = "topic"
metadata["parent_peer_id"] = fmt.Sprintf("%d", threadID)
}
c.HandleMessage(c.ctx,
peer,
messageID,
platformID,
chatIDStr,
compositeChatID,
content,
mediaPaths,
metadata,
@ -668,50 +624,25 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
return c.downloadFileWithInfo(file, ext)
}
func parseChatID(chatIDStr string) (int64, int, error) {
trimmed := strings.TrimSpace(chatIDStr)
if trimmed == "" {
return 0, 0, fmt.Errorf("empty chat ID")
// parseTelegramChatID splits "chatID/threadID" into its components.
// Returns threadID=0 when no "/" is present (non-forum messages).
func parseTelegramChatID(chatID string) (int64, int, error) {
idx := strings.Index(chatID, "/")
if idx == -1 {
cid, err := strconv.ParseInt(chatID, 10, 64)
return cid, 0, err
}
parts := strings.Split(trimmed, "/")
if len(parts) > 2 {
return 0, 0, fmt.Errorf("invalid chat ID format: %q", chatIDStr)
}
cid, err := strconv.ParseInt(parts[0], 10, 64)
cid, err := strconv.ParseInt(chatID[:idx], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("invalid chat ID %q: %w", parts[0], err)
return 0, 0, err
}
tid := 0
if len(parts) == 2 {
if parts[1] == "" {
return 0, 0, fmt.Errorf("invalid thread ID in %q", chatIDStr)
}
tid, err = strconv.Atoi(parts[1])
if err != nil {
return 0, 0, fmt.Errorf("invalid thread ID %q: %w", parts[1], err)
}
if tid < 0 {
return 0, 0, fmt.Errorf("thread ID must be non-negative: %d", tid)
}
tid, err := strconv.Atoi(chatID[idx+1:])
if err != nil {
return 0, 0, fmt.Errorf("invalid thread ID in chat ID %q: %w", chatID, err)
}
return cid, tid, nil
}
func formatChatID(chatID int64, threadID int) string {
if threadID != 0 {
return fmt.Sprintf("%d/%d", chatID, threadID)
}
return fmt.Sprintf("%d", chatID)
}
func isLikelyPrivateChatID(chatID int64) bool {
return chatID > 0
}
func markdownToTelegramHTML(text string) string {
if text == "" {
return ""

View file

@ -0,0 +1,85 @@
package telegram
import (
"context"
"fmt"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/sipeed/picoclaw/pkg/channels"
)
// SendWithID implements channels.MessageSenderWithID.
// It sends a message and returns the platform message ID.
func (c *TelegramChannel) SendWithID(ctx context.Context, chatID string, content string) (string, error) {
if !c.IsRunning() {
return "", channels.ErrNotRunning
}
cid, tid, err := parseTelegramChatID(chatID)
if err != nil {
return "", fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
}
htmlContent := markdownToTelegramHTML(content)
tgMsg := tu.Message(tu.ID(cid), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
tgMsg.MessageThreadID = tid
sent, err := c.bot.SendMessage(ctx, tgMsg)
if err != nil {
// Fallback to plain text
tgMsg.ParseMode = ""
sent, err = c.bot.SendMessage(ctx, tgMsg)
if err != nil {
return "", fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}
return fmt.Sprintf("%d", sent.MessageID), nil
}
// SendDraft implements channels.DraftSender.
// It uses Telegram Bot API's sendMessageDraft for progressive message streaming
// without the "edited" indicator. In groups, draft is used for dedicated topics only.
func (c *TelegramChannel) SendDraft(ctx context.Context, chatID string, draftID int, content string) error {
if !c.IsRunning() {
return channels.ErrNotRunning
}
cid, tid, err := parseTelegramChatID(chatID)
if err != nil {
return fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
}
if !isLikelyPrivateChatID(cid) && tid == 0 {
return fmt.Errorf("telegram draft unsupported for non-threaded group chat: %w", channels.ErrSendFailed)
}
htmlContent := markdownToTelegramHTML(content)
params := &telego.SendMessageDraftParams{
ChatID: cid,
MessageThreadID: tid,
DraftID: draftID,
Text: htmlContent,
ParseMode: telego.ModeHTML,
}
if err = c.bot.SendMessageDraft(ctx, params); err != nil {
// HTML parse failure — retry as plain text
params.ParseMode = ""
params.Text = content
return c.bot.SendMessageDraft(ctx, params)
}
return nil
}
// formatChatID formats a chat ID with optional thread ID as "chatID/threadID".
func formatChatID(chatID int64, threadID int) string {
if threadID != 0 {
return fmt.Sprintf("%d/%d", chatID, threadID)
}
return fmt.Sprintf("%d", chatID)
}
// isLikelyPrivateChatID returns true for positive chat IDs (private chats).
func isLikelyPrivateChatID(chatID int64) bool {
return chatID > 0
}

View file

@ -0,0 +1,54 @@
package telegram
import (
"testing"
)
func TestParseTelegramChatID(t *testing.T) {
tests := []struct {
name string
input string
wantCID int64
wantTID int
wantErr bool
}{
{name: "plain private", input: "12345", wantCID: 12345, wantTID: 0},
{name: "group topic", input: "-100123/45", wantCID: -100123, wantTID: 45},
{name: "topic zero", input: "-100/0", wantCID: -100, wantTID: 0},
{name: "bad chat", input: "abc/def", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotCID, gotTID, err := parseTelegramChatID(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("parseTelegramChatID(%q) expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("parseTelegramChatID(%q) unexpected error: %v", tc.input, err)
}
if gotCID != tc.wantCID || gotTID != tc.wantTID {
t.Fatalf(
"parseTelegramChatID(%q) = (%d, %d), want (%d, %d)",
tc.input,
gotCID,
gotTID,
tc.wantCID,
tc.wantTID,
)
}
})
}
}
func TestFormatChatID(t *testing.T) {
if got := formatChatID(-100, 42); got != "-100/42" {
t.Fatalf("formatChatID(-100, 42) = %q, want %q", got, "-100/42")
}
if got := formatChatID(12345, 0); got != "12345" {
t.Fatalf("formatChatID(12345, 0) = %q, want %q", got, "12345")
}
}

View file

@ -1,50 +1,462 @@
package telegram
import "testing"
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"time"
func TestParseChatID(t *testing.T) {
tests := []struct {
name string
input string
wantCID int64
wantTID int
wantErr bool
}{
{name: "plain private", input: "12345", wantCID: 12345, wantTID: 0},
{name: "group topic", input: "-100123/45", wantCID: -100123, wantTID: 45},
{name: "trim spaces", input: " -100200/7 ", wantCID: -100200, wantTID: 7},
{name: "topic zero", input: "-100/0", wantCID: -100, wantTID: 0},
{name: "empty", input: "", wantErr: true},
{name: "bad chat", input: "abc/def", wantErr: true},
{name: "missing topic", input: "-100/", wantErr: true},
{name: "too many parts", input: "-100/1/2", wantErr: true},
{name: "negative topic", input: "-100/-1", wantErr: true},
}
"github.com/mymmrac/telego"
ta "github.com/mymmrac/telego/telegoapi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotCID, gotTID, err := parseChatID(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("parseChatID(%q) expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("parseChatID(%q) unexpected error: %v", tc.input, err)
}
if gotCID != tc.wantCID || gotTID != tc.wantTID {
t.Fatalf("parseChatID(%q) = (%d, %d), want (%d, %d)", tc.input, gotCID, gotTID, tc.wantCID, tc.wantTID)
}
})
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
)
const testToken = "1234567890:aaaabbbbaaaabbbbaaaabbbbaaaabbbbccc"
// stubCaller implements ta.Caller for testing.
type stubCaller struct {
calls []stubCall
callFn func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error)
}
type stubCall struct {
URL string
Data *ta.RequestData
}
func (s *stubCaller) Call(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
s.calls = append(s.calls, stubCall{URL: url, Data: data})
return s.callFn(ctx, url, data)
}
// stubConstructor implements ta.RequestConstructor for testing.
type stubConstructor struct{}
func (s *stubConstructor) JSONRequest(parameters any) (*ta.RequestData, error) {
return &ta.RequestData{}, nil
}
func (s *stubConstructor) MultipartRequest(
parameters map[string]string,
files map[string]ta.NamedReader,
) (*ta.RequestData, error) {
return &ta.RequestData{}, nil
}
// successResponse returns a ta.Response that telego will treat as a successful SendMessage.
func successResponse(t *testing.T) *ta.Response {
t.Helper()
msg := &telego.Message{MessageID: 1}
b, err := json.Marshal(msg)
require.NoError(t, err)
return &ta.Response{Ok: true, Result: b}
}
// newTestChannel creates a TelegramChannel with a mocked bot for unit testing.
func newTestChannel(t *testing.T, caller *stubCaller) *TelegramChannel {
t.Helper()
bot, err := telego.NewBot(testToken,
telego.WithAPICaller(caller),
telego.WithRequestConstructor(&stubConstructor{}),
telego.WithDiscardLogger(),
)
require.NoError(t, err)
base := channels.NewBaseChannel("telegram", nil, nil, nil,
channels.WithMaxMessageLength(4000),
)
base.SetRunning(true)
return &TelegramChannel{
BaseChannel: base,
bot: bot,
chatIDs: make(map[string]int64),
}
}
func TestFormatChatID(t *testing.T) {
if got := formatChatID(-100, 42); got != "-100/42" {
t.Fatalf("formatChatID(-100, 42) = %q, want %q", got, "-100/42")
}
if got := formatChatID(12345, 0); got != "12345" {
t.Fatalf("formatChatID(12345, 0) = %q, want %q", got, "12345")
func TestSend_EmptyContent(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
t.Fatal("SendMessage should not be called for empty content")
return nil, nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "",
})
assert.NoError(t, err)
assert.Empty(t, caller.calls, "no API calls should be made for empty content")
}
func TestSend_ShortMessage_SingleCall(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello, world!",
})
assert.NoError(t, err)
assert.Len(t, caller.calls, 1, "short message should result in exactly one SendMessage call")
}
func TestSend_LongMessage_SingleCall(t *testing.T) {
// With WithMaxMessageLength(4000), the Manager pre-splits messages before
// they reach Send(). A message at exactly 4000 chars should go through
// as a single SendMessage call (no re-split needed since HTML expansion
// won't exceed 4096 for plain text).
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
longContent := strings.Repeat("a", 4000)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
assert.NoError(t, err)
assert.Len(t, caller.calls, 1, "pre-split message within limit should result in one SendMessage call")
}
func TestSend_HTMLFallback_PerChunk(t *testing.T) {
callCount := 0
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
callCount++
// Fail on odd calls (HTML attempt), succeed on even calls (plain text fallback)
if callCount%2 == 1 {
return nil, errors.New("Bad Request: can't parse entities")
}
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello **world**",
})
assert.NoError(t, err)
// One short message → 1 HTML attempt (fail) + 1 plain text fallback (success) = 2 calls
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text fallback")
}
func TestSend_HTMLFallback_BothFail(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return nil, errors.New("send failed")
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
assert.Error(t, err)
assert.True(t, errors.Is(err, channels.ErrTemporary), "error should wrap ErrTemporary")
assert.Equal(t, 2, len(caller.calls), "should have HTML attempt + plain text attempt")
}
func TestSend_LongMessage_HTMLFallback_StopsOnError(t *testing.T) {
// With a long message that gets split into 2 chunks, if both HTML and
// plain text fail on the first chunk, Send should return early.
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return nil, errors.New("send failed")
},
}
ch := newTestChannel(t, caller)
longContent := strings.Repeat("x", 4001)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: longContent,
})
assert.Error(t, err)
// Should fail on the first chunk (2 calls: HTML + fallback), never reaching the second chunk.
assert.Equal(t, 2, len(caller.calls), "should stop after first chunk fails both HTML and plain text")
}
func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
// Create markdown whose length is <= 4000 but whose HTML expansion is much longer.
// "**a** " (6 chars) becomes "<b>a</b> " (9 chars) in HTML, so repeating it many times
// yields HTML that exceeds Telegram's limit while markdown stays within it.
markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars
assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size")
htmlExpanded := markdownToTelegramHTML(markdownContent)
assert.Greater(
t, len([]rune(htmlExpanded)), 4096,
"HTML expansion must exceed Telegram limit for this test to be meaningful",
)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: markdownContent,
})
assert.NoError(t, err)
assert.Greater(
t, len(caller.calls), 1,
"markdown-short but HTML-long message should be split into multiple SendMessage calls",
)
}
func TestSend_NotRunning(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
t.Fatal("should not be called")
return nil, nil
},
}
ch := newTestChannel(t, caller)
ch.SetRunning(false)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "12345",
Content: "Hello",
})
assert.ErrorIs(t, err, channels.ErrNotRunning)
assert.Empty(t, caller.calls)
}
func TestSend_InvalidChatID(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
t.Fatal("should not be called")
return nil, nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "not-a-number",
Content: "Hello",
})
assert.Error(t, err)
assert.True(t, errors.Is(err, channels.ErrSendFailed), "error should wrap ErrSendFailed")
assert.Empty(t, caller.calls)
}
func TestParseTelegramChatID_Plain(t *testing.T) {
cid, tid, err := parseTelegramChatID("12345")
assert.NoError(t, err)
assert.Equal(t, int64(12345), cid)
assert.Equal(t, 0, tid)
}
func TestParseTelegramChatID_NegativeGroup(t *testing.T) {
cid, tid, err := parseTelegramChatID("-1001234567890")
assert.NoError(t, err)
assert.Equal(t, int64(-1001234567890), cid)
assert.Equal(t, 0, tid)
}
func TestParseTelegramChatID_WithThreadID(t *testing.T) {
cid, tid, err := parseTelegramChatID("-1001234567890/42")
assert.NoError(t, err)
assert.Equal(t, int64(-1001234567890), cid)
assert.Equal(t, 42, tid)
}
func TestParseTelegramChatID_GeneralTopic(t *testing.T) {
cid, tid, err := parseTelegramChatID("-100123/1")
assert.NoError(t, err)
assert.Equal(t, int64(-100123), cid)
assert.Equal(t, 1, tid)
}
func TestParseTelegramChatID_Invalid(t *testing.T) {
_, _, err := parseTelegramChatID("not-a-number")
assert.Error(t, err)
}
func TestParseTelegramChatID_InvalidThreadID(t *testing.T) {
_, _, err := parseTelegramChatID("-100123/not-a-thread")
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid thread ID")
}
func TestSend_WithForumThreadID(t *testing.T) {
caller := &stubCaller{
callFn: func(ctx context.Context, url string, data *ta.RequestData) (*ta.Response, error) {
return successResponse(t), nil
},
}
ch := newTestChannel(t, caller)
err := ch.Send(context.Background(), bus.OutboundMessage{
ChatID: "-1001234567890/42",
Content: "Hello from topic",
})
assert.NoError(t, err)
assert.Len(t, caller.calls, 1)
}
func TestHandleMessage_ForumTopic_SetsMetadata(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
msg := &telego.Message{
Text: "hello from topic",
MessageID: 10,
MessageThreadID: 42,
Chat: telego.Chat{
ID: -1001234567890,
Type: "supergroup",
IsForum: true,
},
From: &telego.User{
ID: 7,
FirstName: "Alice",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
require.True(t, ok, "expected inbound message")
// Composite chatID should include thread ID
assert.Equal(t, "-1001234567890/42", inbound.ChatID)
// Peer ID should include thread ID for session key isolation
assert.Equal(t, "group", inbound.Peer.Kind)
assert.Equal(t, "-1001234567890/42", inbound.Peer.ID)
// Parent peer metadata should be set for agent binding
assert.Equal(t, "topic", inbound.Metadata["parent_peer_kind"])
assert.Equal(t, "42", inbound.Metadata["parent_peer_id"])
}
func TestHandleMessage_NoForum_NoThreadMetadata(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
msg := &telego.Message{
Text: "regular group message",
MessageID: 11,
Chat: telego.Chat{
ID: -100999,
Type: "group",
},
From: &telego.User{
ID: 8,
FirstName: "Bob",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
require.True(t, ok)
// Plain chatID without thread suffix
assert.Equal(t, "-100999", inbound.ChatID)
// Peer ID should be raw chat ID (no thread suffix)
assert.Equal(t, "group", inbound.Peer.Kind)
assert.Equal(t, "-100999", inbound.Peer.ID)
// No parent peer metadata
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}
func TestHandleMessage_ReplyThread_NonForum_NoIsolation(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
// In regular groups, reply threads set MessageThreadID to the original
// message ID. This should NOT trigger per-thread session isolation.
msg := &telego.Message{
Text: "reply in thread",
MessageID: 20,
MessageThreadID: 15,
Chat: telego.Chat{
ID: -100999,
Type: "supergroup",
IsForum: false,
},
From: &telego.User{
ID: 9,
FirstName: "Carol",
},
}
err := ch.handleMessage(context.Background(), msg)
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
require.True(t, ok)
// chatID should NOT include thread suffix for non-forum groups
assert.Equal(t, "-100999", inbound.ChatID)
// Peer ID should be raw chat ID (shared session for whole group)
assert.Equal(t, "group", inbound.Peer.Kind)
assert.Equal(t, "-100999", inbound.Peer.ID)
// No parent peer metadata
assert.Empty(t, inbound.Metadata["parent_peer_kind"])
assert.Empty(t, inbound.Metadata["parent_peer_id"])
}

View file

@ -209,7 +209,7 @@ func TestWeComAppVerifySignature(t *testing.T) {
}
})
t.Run("empty token skips verification", func(t *testing.T) {
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
cfgEmpty := config.WeComAppConfig{
CorpID: "test_corp_id",
CorpSecret: "test_secret",
@ -218,8 +218,8 @@ func TestWeComAppVerifySignature(t *testing.T) {
}
chEmpty, _ := NewWeComAppChannel(cfgEmpty, msgBus)
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should reject verification (fail-closed)")
}
})
}

View file

@ -189,8 +189,7 @@ func TestWeComBotVerifySignature(t *testing.T) {
}
})
t.Run("empty token skips verification", func(t *testing.T) {
// Create a channel manually with empty token to test the behavior
t.Run("empty token rejects verification (fail-closed)", func(t *testing.T) {
cfgEmpty := config.WeComConfig{
Token: "",
WebhookURL: "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=test",
@ -199,8 +198,8 @@ func TestWeComBotVerifySignature(t *testing.T) {
config: cfgEmpty,
}
if !verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should skip verification and return true")
if verifySignature(chEmpty.config.Token, "any_sig", "any_ts", "any_nonce", "any_msg") {
t.Error("empty token should reject verification (fail-closed)")
}
})
}

View file

@ -31,7 +31,7 @@ func computeSignature(token, timestamp, nonce, encrypt string) string {
// This is a common function used by both WeCom Bot and WeCom App
func verifySignature(token, msgSignature, timestamp, nonce, msgEncrypt string) bool {
if token == "" {
return true // Skip verification if token is not set
return false
}
return computeSignature(token, timestamp, nonce, msgEncrypt) == msgSignature
}

View file

@ -12,5 +12,6 @@ func BuiltinDefinitions() []Definition {
listCommand(),
switchCommand(),
checkCommand(),
clearCommand(),
}
}

20
pkg/commands/cmd_clear.go Normal file
View file

@ -0,0 +1,20 @@
package commands
import "context"
func clearCommand() Definition {
return Definition{
Name: "clear",
Description: "Clear the chat history",
Usage: "/clear",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.ClearHistory == nil {
return req.Reply(unavailableMsg)
}
if err := rt.ClearHistory(); err != nil {
return req.Reply("Failed to clear chat history: " + err.Error())
}
return req.Reply("Chat history cleared!")
},
}
}

View file

@ -13,4 +13,5 @@ type Runtime struct {
GetEnabledChannels func() []string
SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error
ClearHistory func() error
}

View file

@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"strings"
"sync/atomic"
"github.com/caarlos0/env/v11"
@ -16,6 +17,8 @@ var rrCounter atomic.Uint64
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
// It also supports parsing comma-separated strings from environment variables,
// including both English (,) and Chinese () commas.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
@ -47,6 +50,30 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
return nil
}
// UnmarshalText implements encoding.TextUnmarshaler to support env variable parsing.
// It handles comma-separated values with both English (,) and Chinese () commas.
func (f *FlexibleStringSlice) UnmarshalText(text []byte) error {
if len(text) == 0 {
*f = nil
return nil
}
s := string(text)
// Replace Chinese comma with English comma, then split
s = strings.ReplaceAll(s, "", ",")
parts := strings.Split(s, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
*f = result
return nil
}
type Config struct {
Agents AgentsConfig `json:"agents"`
Bindings []AgentBinding `json:"bindings,omitempty"`
@ -58,6 +85,17 @@ type Config struct {
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
Voice VoiceConfig `json:"voice"`
// BuildInfo contains build-time version information
BuildInfo BuildInfo `json:"build_info,omitempty"`
}
// BuildInfo contains build-time version information
type BuildInfo struct {
Version string `json:"version"`
GitCommit string `json:"git_commit"`
BuildTime string `json:"build_time"`
GoVersion string `json:"go_version"`
}
// MarshalJSON implements custom JSON marshaling for Config
@ -141,7 +179,7 @@ type AgentConfig struct {
}
type SubagentsConfig struct {
Enabled bool `json:"enabled,omitempty"`
Enabled bool `json:"enabled,omitempty"` // Fork-only: gate orchestration
AllowAgents []string `json:"allow_agents,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
}
@ -189,18 +227,18 @@ type AgentDefaults struct {
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
Routing *RoutingConfig `json:"routing,omitempty"`
}
@ -266,19 +304,20 @@ type WhatsAppConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
SubagentThreadID int `json:"subagent_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"`
HeartbeatThreadID int `json:"heartbeat_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_HEARTBEAT_THREAD_ID"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
}
type FeishuConfig struct {
@ -320,6 +359,8 @@ type QQConfig struct {
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
MaxMessageLength int `json:"max_message_length" env:"PICOCLAW_CHANNELS_QQ_MAX_MESSAGE_LENGTH"`
SendMarkdown bool `json:"send_markdown" env:"PICOCLAW_CHANNELS_QQ_SEND_MARKDOWN"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_QQ_REASONING_CHANNEL_ID"`
}
@ -344,16 +385,17 @@ type SlackConfig struct {
}
type MatrixConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MATRIX_ENABLED"`
Homeserver string `json:"homeserver" env:"PICOCLAW_CHANNELS_MATRIX_HOMESERVER"`
UserID string `json:"user_id" env:"PICOCLAW_CHANNELS_MATRIX_USER_ID"`
AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_MATRIX_ACCESS_TOKEN"`
DeviceID string `json:"device_id,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_DEVICE_ID"`
JoinOnInvite bool `json:"join_on_invite" env:"PICOCLAW_CHANNELS_MATRIX_JOIN_ON_INVITE"`
MessageFormat string `json:"message_format,omitempty" env:"PICOCLAW_CHANNELS_MATRIX_MESSAGE_FORMAT"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
}
type LINEConfig struct {
@ -467,6 +509,10 @@ type DevicesConfig struct {
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
type VoiceConfig struct {
EchoTranscription bool `json:"echo_transcription" env:"PICOCLAW_VOICE_ECHO_TRANSCRIPTION"`
}
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI OpenAIProviderConfig `json:"openai"`
@ -489,6 +535,8 @@ type ProvidersConfig struct {
Qwen ProviderConfig `json:"qwen"`
Mistral ProviderConfig `json:"mistral"`
Avian ProviderConfig `json:"avian"`
Minimax ProviderConfig `json:"minimax"`
LongCat ProviderConfig `json:"longcat"`
}
// IsEmpty checks if all provider configs are empty (no API keys or API bases set)
@ -514,7 +562,9 @@ func (p ProvidersConfig) IsEmpty() bool {
p.Antigravity.APIKey == "" && p.Antigravity.APIBase == "" &&
p.Qwen.APIKey == "" && p.Qwen.APIBase == "" &&
p.Mistral.APIKey == "" && p.Mistral.APIBase == "" &&
p.Avian.APIKey == "" && p.Avian.APIBase == ""
p.Avian.APIKey == "" && p.Avian.APIBase == "" &&
p.Minimax.APIKey == "" && p.Minimax.APIBase == "" &&
p.LongCat.APIKey == "" && p.LongCat.APIBase == ""
}
// MarshalJSON implements custom JSON marshaling for ProvidersConfig
@ -560,12 +610,13 @@ type ModelConfig struct {
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
// Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent)
RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent)
}
// Validate checks if the ModelConfig has all required fields.
@ -584,21 +635,31 @@ type GatewayConfig struct {
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
}
type ToolDiscoveryConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_DISCOVERY_ENABLED"`
TTL int `json:"ttl" env:"PICOCLAW_TOOLS_DISCOVERY_TTL"`
MaxSearchResults int `json:"max_search_results" env:"PICOCLAW_MAX_SEARCH_RESULTS"`
UseBM25 bool `json:"use_bm25" env:"PICOCLAW_TOOLS_DISCOVERY_USE_BM25"`
UseRegex bool `json:"use_regex" env:"PICOCLAW_TOOLS_DISCOVERY_USE_REGEX"`
}
type ToolConfig struct {
Enabled bool `json:"enabled" env:"ENABLED"`
}
type BraveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_BRAVE_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEY"`
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_BRAVE_API_KEYS"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_BRAVE_MAX_RESULTS"`
}
type TavilyConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_TAVILY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEY"`
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_TAVILY_API_KEYS"`
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_TAVILY_BASE_URL"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
}
type DuckDuckGoConfig struct {
@ -607,9 +668,10 @@ type DuckDuckGoConfig struct {
}
type PerplexityConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"`
APIKeys []string `json:"api_keys" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
type SearXNGConfig struct {
@ -650,6 +712,7 @@ type CronToolsConfig struct {
type ExecConfig struct {
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_EXEC_"`
EnableDenyPatterns bool ` env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS" json:"enable_deny_patterns"`
AllowRemote bool ` env:"PICOCLAW_TOOLS_EXEC_ALLOW_REMOTE" json:"allow_remote"`
CustomDenyPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS" json:"custom_deny_patterns"`
CustomAllowPatterns []string ` env:"PICOCLAW_TOOLS_EXEC_CUSTOM_ALLOW_PATTERNS" json:"custom_allow_patterns"`
TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s)
@ -668,6 +731,11 @@ type MediaCleanupConfig struct {
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
}
type ReadFileToolConfig struct {
Enabled bool `json:"enabled"`
MaxReadFileSize int `json:"max_read_file_size"`
}
type ToolsConfig struct {
AllowReadPaths []string `json:"allow_read_paths" env:"PICOCLAW_TOOLS_ALLOW_READ_PATHS"`
AllowWritePaths []string `json:"allow_write_paths" env:"PICOCLAW_TOOLS_ALLOW_WRITE_PATHS"`
@ -684,7 +752,7 @@ type ToolsConfig struct {
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
@ -736,7 +804,8 @@ type MCPServerConfig struct {
// MCPConfig defines configuration for all MCP servers
type MCPConfig struct {
ToolConfig `envPrefix:"PICOCLAW_TOOLS_MCP_"`
ToolConfig ` envPrefix:"PICOCLAW_TOOLS_MCP_"`
Discovery ToolDiscoveryConfig ` json:"discovery"`
// Servers is a map of server name to server configuration
Servers map[string]MCPServerConfig `json:"servers,omitempty"`
}
@ -936,6 +1005,29 @@ func (c *Config) ValidateModelList() error {
return nil
}
func MergeAPIKeys(apiKey string, apiKeys []string) []string {
seen := make(map[string]struct{})
var all []string
if k := strings.TrimSpace(apiKey); k != "" {
if _, exists := seen[k]; !exists {
seen[k] = struct{}{}
all = append(all, k)
}
}
for _, k := range apiKeys {
if trimmed := strings.TrimSpace(k); trimmed != "" {
if _, exists := seen[trimmed]; !exists {
seen[trimmed] = struct{}{}
all = append(all, trimmed)
}
}
}
return all
}
func (t *ToolsConfig) IsToolEnabled(name string) bool {
switch name {
case "web":

View file

@ -0,0 +1,123 @@
package config
import (
"encoding/json"
"testing"
)
func TestAgentDefaults_PlanModel_StringParse(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"plan_model": "anthropic/claude-sonnet-4-6",
"plan_model_fallbacks": ["openai/gpt-4o"],
"max_tokens": 8192,
"max_tool_iterations": 20
}
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" {
t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel)
}
if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 ||
cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" {
t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks)
}
}
func TestAgentConfig_PlanModel_ObjectParse(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"max_tokens": 8192,
"max_tool_iterations": 20
},
"list": [
{
"id": "main",
"plan_model": "anthropic/claude-sonnet-4-6"
},
{
"id": "advanced",
"plan_model": {
"primary": "anthropic/claude-opus-4",
"fallbacks": ["anthropic/claude-sonnet-4-6"]
}
}
]
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Agents.List) != 2 {
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
}
main := cfg.Agents.List[0]
if main.PlanModel == nil || main.PlanModel.Primary != "anthropic/claude-sonnet-4-6" {
t.Errorf("main.PlanModel = %+v, want primary 'anthropic/claude-sonnet-4-6'", main.PlanModel)
}
adv := cfg.Agents.List[1]
if adv.PlanModel == nil || adv.PlanModel.Primary != "anthropic/claude-opus-4" {
t.Errorf("advanced.PlanModel = %+v, want primary 'anthropic/claude-opus-4'", adv.PlanModel)
}
if len(adv.PlanModel.Fallbacks) != 1 || adv.PlanModel.Fallbacks[0] != "anthropic/claude-sonnet-4-6" {
t.Errorf("advanced.PlanModel.Fallbacks = %v", adv.PlanModel.Fallbacks)
}
}
func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"plan_model": "default-plan-model",
"plan_model_fallbacks": ["default-fallback"],
"max_tokens": 8192,
"max_tool_iterations": 20
},
"list": [
{
"id": "custom",
"plan_model": {
"primary": "custom-plan-model",
"fallbacks": ["custom-fallback"]
}
}
]
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
custom := cfg.Agents.List[0]
if custom.PlanModel == nil || custom.PlanModel.Primary != "custom-plan-model" {
t.Errorf("custom.PlanModel.Primary = %v, want 'custom-plan-model'", custom.PlanModel)
}
if len(custom.PlanModel.Fallbacks) != 1 || custom.PlanModel.Fallbacks[0] != "custom-fallback" {
t.Errorf("custom.PlanModel.Fallbacks = %v, want [custom-fallback]", custom.PlanModel.Fallbacks)
}
if cfg.Agents.Defaults.PlanModel != "default-plan-model" {
t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel)
}
}

View file

@ -296,7 +296,7 @@ func TestDefaultConfig_WebTools(t *testing.T) {
if cfg.Tools.Web.Brave.MaxResults != 5 {
t.Error("Expected Brave MaxResults 5, got ", cfg.Tools.Web.Brave.MaxResults)
}
if cfg.Tools.Web.Brave.APIKey != "" {
if len(cfg.Tools.Web.Brave.APIKeys) != 0 {
t.Error("Brave API key should be empty by default")
}
if cfg.Tools.Web.DuckDuckGo.MaxResults != 5 {
@ -384,6 +384,13 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) {
}
}
func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) {
cfg := DefaultConfig()
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("DefaultConfig().Tools.Exec.AllowRemote should be true")
}
}
func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
@ -400,6 +407,22 @@ func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) {
}
}
func TestLoadConfig_ExecAllowRemoteDefaultsTrueWhenUnset(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
if err := os.WriteFile(configPath, []byte(`{"tools":{"exec":{"enable_deny_patterns":true}}}`), 0o600); err != nil {
t.Fatalf("WriteFile() error: %v", err)
}
cfg, err := LoadConfig(configPath)
if err != nil {
t.Fatalf("LoadConfig() error: %v", err)
}
if !cfg.Tools.Exec.AllowRemote {
t.Fatal("tools.exec.allow_remote should remain true when unset in config file")
}
}
func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
@ -416,133 +439,12 @@ func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) {
}
}
func TestAgentDefaults_PlanModel_StringParse(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"plan_model": "anthropic/claude-sonnet-4-6",
"plan_model_fallbacks": ["openai/gpt-4o"],
"max_tokens": 8192,
"max_tool_iterations": 20
}
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.Agents.Defaults.PlanModel != "anthropic/claude-sonnet-4-6" {
t.Errorf("PlanModel = %q, want 'anthropic/claude-sonnet-4-6'", cfg.Agents.Defaults.PlanModel)
}
if len(cfg.Agents.Defaults.PlanModelFallbacks) != 1 ||
cfg.Agents.Defaults.PlanModelFallbacks[0] != "openai/gpt-4o" {
t.Errorf("PlanModelFallbacks = %v, want [openai/gpt-4o]", cfg.Agents.Defaults.PlanModelFallbacks)
}
}
func TestAgentConfig_PlanModel_ObjectParse(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"max_tokens": 8192,
"max_tool_iterations": 20
},
"list": [
{
"id": "main",
"plan_model": "anthropic/claude-sonnet-4-6"
},
{
"id": "advanced",
"plan_model": {
"primary": "anthropic/claude-opus-4",
"fallbacks": ["anthropic/claude-sonnet-4-6"]
}
}
]
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if len(cfg.Agents.List) != 2 {
t.Fatalf("agents.list len = %d, want 2", len(cfg.Agents.List))
}
// String form
main := cfg.Agents.List[0]
if main.PlanModel == nil || main.PlanModel.Primary != "anthropic/claude-sonnet-4-6" {
t.Errorf("main.PlanModel = %+v, want primary 'anthropic/claude-sonnet-4-6'", main.PlanModel)
}
// Object form with fallbacks
adv := cfg.Agents.List[1]
if adv.PlanModel == nil || adv.PlanModel.Primary != "anthropic/claude-opus-4" {
t.Errorf("advanced.PlanModel = %+v, want primary 'anthropic/claude-opus-4'", adv.PlanModel)
}
if len(adv.PlanModel.Fallbacks) != 1 || adv.PlanModel.Fallbacks[0] != "anthropic/claude-sonnet-4-6" {
t.Errorf("advanced.PlanModel.Fallbacks = %v", adv.PlanModel.Fallbacks)
}
}
func TestAgentConfig_PlanModel_OverridesDefaults(t *testing.T) {
jsonData := `{
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
"model": "glm-4.7",
"plan_model": "default-plan-model",
"plan_model_fallbacks": ["default-fallback"],
"max_tokens": 8192,
"max_tool_iterations": 20
},
"list": [
{
"id": "custom",
"plan_model": {
"primary": "custom-plan-model",
"fallbacks": ["custom-fallback"]
}
}
]
}
}`
cfg := DefaultConfig()
if err := json.Unmarshal([]byte(jsonData), cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// Agent-level plan_model should override defaults
custom := cfg.Agents.List[0]
if custom.PlanModel == nil || custom.PlanModel.Primary != "custom-plan-model" {
t.Errorf("custom.PlanModel.Primary = %v, want 'custom-plan-model'", custom.PlanModel)
}
if len(custom.PlanModel.Fallbacks) != 1 || custom.PlanModel.Fallbacks[0] != "custom-fallback" {
t.Errorf("custom.PlanModel.Fallbacks = %v, want [custom-fallback]", custom.PlanModel.Fallbacks)
}
// Defaults should still be intact
if cfg.Agents.Defaults.PlanModel != "default-plan-model" {
t.Errorf("defaults.PlanModel = %q, want 'default-plan-model'", cfg.Agents.Defaults.PlanModel)
}
}
func TestLoadConfig_WebToolsProxy(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.json")
configJSON := `{
"agents": {"defaults":{"workspace":"./workspace","model":"gpt4","max_tokens":8192,"max_tool_iterations":20}},
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.2","api_key":"x"}],
"model_list": [{"model_name":"gpt4","model":"openai/gpt-5.4","api_key":"x"}],
"tools": {"web":{"proxy":"http://127.0.0.1:7890"}}
}`
if err := os.WriteFile(configPath, []byte(configJSON), 0o600); err != nil {
@ -603,3 +505,119 @@ func TestDefaultConfig_WorkspacePath_WithPicoclawHome(t *testing.T) {
t.Errorf("Workspace path with PICOCLAW_HOME = %q, want %q", cfg.Agents.Defaults.Workspace, want)
}
}
// TestFlexibleStringSlice_UnmarshalText tests UnmarshalText with various comma separators
func TestFlexibleStringSlice_UnmarshalText(t *testing.T) {
tests := []struct {
name string
input string
expected []string
}{
{
name: "English commas only",
input: "123,456,789",
expected: []string{"123", "456", "789"},
},
{
name: "Chinese commas only",
input: "123456789",
expected: []string{"123", "456", "789"},
},
{
name: "Mixed English and Chinese commas",
input: "123,456789",
expected: []string{"123", "456", "789"},
},
{
name: "Single value",
input: "123",
expected: []string{"123"},
},
{
name: "Values with whitespace",
input: " 123 , 456 , 789 ",
expected: []string{"123", "456", "789"},
},
{
name: "Empty string",
input: "",
expected: nil,
},
{
name: "Only commas - English",
input: ",,",
expected: []string{},
},
{
name: "Only commas - Chinese",
input: "",
expected: []string{},
},
{
name: "Mixed commas with empty parts",
input: "123,,456789",
expected: []string{"123", "456", "789"},
},
{
name: "Complex mixed values",
input: "user1@example.comuser2@test.com, admin@domain.org",
expected: []string{"user1@example.com", "user2@test.com", "admin@domain.org"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(tt.input))
if err != nil {
t.Fatalf("UnmarshalText(%q) error = %v", tt.input, err)
}
if tt.expected == nil {
if f != nil {
t.Errorf("UnmarshalText(%q) = %v, want nil", tt.input, f)
}
return
}
if len(f) != len(tt.expected) {
t.Errorf("UnmarshalText(%q) length = %d, want %d", tt.input, len(f), len(tt.expected))
return
}
for i, v := range tt.expected {
if f[i] != v {
t.Errorf("UnmarshalText(%q)[%d] = %q, want %q", tt.input, i, f[i], v)
}
}
})
}
}
// TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency tests nil vs empty slice behavior
func TestFlexibleStringSlice_UnmarshalText_EmptySliceConsistency(t *testing.T) {
t.Run("Empty string returns nil", func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(""))
if err != nil {
t.Fatalf("UnmarshalText error = %v", err)
}
if f != nil {
t.Errorf("Empty string should return nil, got %v", f)
}
})
t.Run("Commas only returns empty slice", func(t *testing.T) {
var f FlexibleStringSlice
err := f.UnmarshalText([]byte(",,,"))
if err != nil {
t.Fatalf("UnmarshalText error = %v", err)
}
if f == nil {
t.Error("Commas only should return empty slice, not nil")
}
if len(f) != 0 {
t.Errorf("Expected empty slice, got %v", f)
}
})
}

View file

@ -34,7 +34,6 @@ func DefaultConfig() *Config {
Temperature: nil, // nil means use provider default
MaxToolIterations: 50,
SummarizeMessageThreshold: 20,
TaskReminderInterval: 5,
SummarizeTokenPercent: 75,
},
},
@ -51,12 +50,10 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{},
},
Telegram: TelegramConfig{
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
Typing: TypingConfig{Enabled: true},
SubagentThreadID: 0,
HeartbeatThreadID: 0,
Enabled: false,
Token: "",
AllowFrom: FlexibleStringSlice{},
Typing: TypingConfig{Enabled: true},
Placeholder: PlaceholderConfig{
Enabled: true,
Text: "Thinking... 💭",
@ -83,10 +80,11 @@ func DefaultConfig() *Config {
AllowFrom: FlexibleStringSlice{},
},
QQ: QQConfig{
Enabled: false,
AppID: "",
AppSecret: "",
AllowFrom: FlexibleStringSlice{},
Enabled: false,
AppID: "",
AppSecret: "",
AllowFrom: FlexibleStringSlice{},
MaxMessageLength: 2000,
},
DingTalk: DingTalkConfig{
Enabled: false,
@ -196,8 +194,8 @@ func DefaultConfig() *Config {
// OpenAI - https://platform.openai.com/api-keys
{
ModelName: "gpt-5.2",
Model: "openai/gpt-5.2",
ModelName: "gpt-5.4",
Model: "openai/gpt-5.4",
APIBase: "https://api.openai.com/v1",
APIKey: "",
},
@ -258,8 +256,8 @@ func DefaultConfig() *Config {
APIKey: "",
},
{
ModelName: "openrouter-gpt-5.2",
Model: "openrouter/openai/gpt-5.2",
ModelName: "openrouter-gpt-5.4",
Model: "openrouter/openai/gpt-5.4",
APIBase: "https://openrouter.ai/api/v1",
APIKey: "",
},
@ -289,6 +287,12 @@ func DefaultConfig() *Config {
},
// Volcengine (火山引擎) - https://console.volcengine.com/ark
{
ModelName: "ark-code-latest",
Model: "volcengine/ark-code-latest",
APIBase: "https://ark.cn-beijing.volces.com/api/v3",
APIKey: "",
},
{
ModelName: "doubao-pro",
Model: "volcengine/doubao-pro-32k",
@ -313,8 +317,8 @@ func DefaultConfig() *Config {
// GitHub Copilot - https://github.com/settings/tokens
{
ModelName: "copilot-gpt-5.2",
Model: "github-copilot/gpt-5.2",
ModelName: "copilot-gpt-5.4",
Model: "github-copilot/gpt-5.4",
APIBase: "http://localhost:4321",
AuthMethod: "oauth",
},
@ -349,6 +353,22 @@ func DefaultConfig() *Config {
APIKey: "",
},
// Minimax - https://api.minimaxi.com/
{
ModelName: "MiniMax-M2.5",
Model: "minimax/MiniMax-M2.5",
APIBase: "https://api.minimaxi.com/v1",
APIKey: "",
},
// LongCat - https://longcat.chat/platform
{
ModelName: "LongCat-Flash-Thinking",
Model: "longcat/LongCat-Flash-Thinking",
APIBase: "https://api.longcat.chat/openai",
APIKey: "",
},
// VLLM (local) - http://localhost:8000
{
ModelName: "local-model",
@ -378,6 +398,13 @@ func DefaultConfig() *Config {
Brave: BraveConfig{
Enabled: false,
APIKey: "",
APIKeys: nil,
MaxResults: 5,
},
Tavily: TavilyConfig{
Enabled: false,
APIKey: "",
APIKeys: nil,
MaxResults: 5,
},
DuckDuckGo: DuckDuckGoConfig{
@ -387,6 +414,7 @@ func DefaultConfig() *Config {
Perplexity: PerplexityConfig{
Enabled: false,
APIKey: "",
APIKeys: nil,
MaxResults: 5,
},
SearXNG: SearXNGConfig{
@ -413,6 +441,7 @@ func DefaultConfig() *Config {
Enabled: true,
},
EnableDenyPatterns: true,
AllowRemote: true,
TimeoutSeconds: 60,
},
Skills: SkillsToolsConfig{
@ -438,6 +467,13 @@ func DefaultConfig() *Config {
ToolConfig: ToolConfig{
Enabled: false,
},
Discovery: ToolDiscoveryConfig{
Enabled: false,
TTL: 5,
MaxSearchResults: 5,
UseBM25: true,
UseRegex: false,
},
Servers: map[string]MCPServerConfig{},
},
AppendFile: ToolConfig{
@ -461,8 +497,9 @@ func DefaultConfig() *Config {
Message: ToolConfig{
Enabled: true,
},
ReadFile: ToolConfig{
Enabled: true,
ReadFile: ReadFileToolConfig{
Enabled: true,
MaxReadFileSize: 64 * 1024, // 64KB
},
Spawn: ToolConfig{
Enabled: true,
@ -488,5 +525,14 @@ func DefaultConfig() *Config {
Enabled: false,
MonitorUSB: true,
},
Voice: VoiceConfig{
EchoTranscription: false,
},
BuildInfo: BuildInfo{
Version: Version,
GitCommit: GitCommit,
BuildTime: BuildTime,
GoVersion: GoVersion,
},
}
}

View file

@ -45,7 +45,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
p := cfg.Providers
result := make([]ModelConfig, 0, 20)
var result []ModelConfig
// Track if we've applied the legacy model name fix (only for first provider)
legacyModelNameApplied := false
@ -61,7 +61,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}
return ModelConfig{
ModelName: "openai",
Model: "openai/gpt-5.2",
Model: "openai/gpt-5.4",
APIKey: p.OpenAI.APIKey,
APIBase: p.OpenAI.APIBase,
Proxy: p.OpenAI.Proxy,
@ -335,7 +335,7 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}
return ModelConfig{
ModelName: "github-copilot",
Model: "github-copilot/gpt-5.2",
Model: "github-copilot/gpt-5.4",
APIBase: p.GitHubCopilot.APIBase,
ConnectMode: p.GitHubCopilot.ConnectMode,
}, true
@ -407,6 +407,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
{
providerNames: []string{"longcat"},
protocol: "longcat",
buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
return ModelConfig{}, false
}
return ModelConfig{
ModelName: "longcat",
Model: "longcat/LongCat-Flash-Thinking",
APIKey: p.LongCat.APIKey,
APIBase: p.LongCat.APIBase,
Proxy: p.LongCat.Proxy,
RequestTimeout: p.LongCat.RequestTimeout,
}, true
},
},
}
// Process each provider migration

View file

@ -31,8 +31,8 @@ func TestConvertProvidersToModelList_OpenAI(t *testing.T) {
if result[0].ModelName != "openai" {
t.Errorf("ModelName = %q, want %q", result[0].ModelName, "openai")
}
if result[0].Model != "openai/gpt-5.2" {
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.2")
if result[0].Model != "openai/gpt-5.4" {
t.Errorf("Model = %q, want %q", result[0].Model, "openai/gpt-5.4")
}
if result[0].APIKey != "sk-test-key" {
t.Errorf("APIKey = %q, want %q", result[0].APIKey, "sk-test-key")
@ -162,14 +162,15 @@ func TestConvertProvidersToModelList_AllProviders(t *testing.T) {
Qwen: ProviderConfig{APIKey: "key17"},
Mistral: ProviderConfig{APIKey: "key18"},
Avian: ProviderConfig{APIKey: "key19"},
LongCat: ProviderConfig{APIKey: "key-longcat"},
},
}
result := ConvertProvidersToModelList(cfg)
// All 21 providers should be converted
if len(result) != 21 {
t.Errorf("len(result) = %d, want 21", len(result))
// All 22 providers should be converted
if len(result) != 22 {
t.Errorf("len(result) = %d, want 22", len(result))
}
}
@ -383,8 +384,8 @@ func TestConvertProvidersToModelList_MultipleProviders_PreservesUserModel(t *tes
for _, mc := range result {
switch mc.ModelName {
case "openai":
if mc.Model != "openai/gpt-5.2" {
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.2")
if mc.Model != "openai/gpt-5.4" {
t.Errorf("OpenAI Model = %q, want %q (default)", mc.Model, "openai/gpt-5.4")
}
case "deepseek":
if mc.Model != "deepseek/deepseek-reasoner" {
@ -557,9 +558,9 @@ func TestConvertProvidersToModelList_NoProviderField_NoModel(t *testing.T) {
// Tests for buildModelWithProtocol helper function
func TestBuildModelWithProtocol_NoPrefix(t *testing.T) {
result := buildModelWithProtocol("openai", "gpt-5.2")
if result != "openai/gpt-5.2" {
t.Errorf("buildModelWithProtocol(openai, gpt-5.2) = %q, want %q", result, "openai/gpt-5.2")
result := buildModelWithProtocol("openai", "gpt-5.4")
if result != "openai/gpt-5.4" {
t.Errorf("buildModelWithProtocol(openai, gpt-5.4) = %q, want %q", result, "openai/gpt-5.4")
}
}

44
pkg/config/version.go Normal file
View file

@ -0,0 +1,44 @@
package config
import (
"fmt"
"runtime"
)
// Build-time variables injected via ldflags during build process.
// These are set by the Makefile or .goreleaser.yaml using the -X flag:
//
// -X github.com/sipeed/picoclaw/pkg/config.Version=<version>
// -X github.com/sipeed/picoclaw/pkg/config.GitCommit=<commit>
// -X github.com/sipeed/picoclaw/pkg/config.BuildTime=<timestamp>
// -X github.com/sipeed/picoclaw/pkg/config.GoVersion=<go-version>
var (
Version = "dev" // Default value when not built with ldflags
GitCommit string // Git commit SHA (short)
BuildTime string // Build timestamp in RFC3339 format
GoVersion string // Go version used for building
)
// FormatVersion returns the version string with optional git commit
func FormatVersion() string {
v := Version
if GitCommit != "" {
v += fmt.Sprintf(" (git: %s)", GitCommit)
}
return v
}
// FormatBuildInfo returns build time and go version info
func FormatBuildInfo() (string, string) {
build := BuildTime
goVer := GoVersion
if goVer == "" {
goVer = runtime.Version()
}
return build, goVer
}
// GetVersion returns the version string
func GetVersion() string {
return Version
}

View file

@ -0,0 +1,92 @@
package config
import (
"runtime"
"testing"
"github.com/stretchr/testify/assert"
)
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 TestGetVersion(t *testing.T) {
oldVersion := Version
t.Cleanup(func() { Version = oldVersion })
Version = "dev"
assert.Equal(t, "dev", GetVersion())
}
func TestGetVersion_Custom(t *testing.T) {
oldVersion := Version
t.Cleanup(func() { Version = oldVersion })
Version = "v1.0.0"
assert.Equal(t, "v1.0.0", GetVersion())
}
func TestVersion_DefaultIsDev(t *testing.T) {
// Reset to default values
oldVersion := Version
Version = "dev"
t.Cleanup(func() { Version = oldVersion })
assert.Equal(t, "dev", Version)
}

View file

@ -2,7 +2,6 @@ package health
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"maps"
@ -13,7 +12,6 @@ import (
type Server struct {
server *http.Server
mux *http.ServeMux
mu sync.RWMutex
ready bool
checks map[string]Check
@ -36,7 +34,6 @@ type StatusResponse struct {
func NewServer(host string, port int) *Server {
mux := http.NewServeMux()
s := &Server{
mux: mux,
ready: false,
checks: make(map[string]Check),
startTime: time.Now(),
@ -49,34 +46,13 @@ func NewServer(host string, port int) *Server {
s.server = &http.Server{
Addr: addr,
Handler: mux,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
return s
}
// Mux returns the underlying ServeMux so additional routes can be registered.
func (s *Server) Mux() *http.ServeMux {
return s.mux
}
// StartTLS starts the server with TLS using the provided certificate and key files.
func (s *Server) StartTLS(certFile, keyFile string) error {
s.mu.Lock()
s.ready = true
s.mu.Unlock()
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("failed to load TLS cert: %w", err)
}
s.server.TLSConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
}
return s.server.ListenAndServeTLS("", "")
}
func (s *Server) Start() error {
s.mu.Lock()
s.ready = true

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