+
+---
+
+🦐 **PicoClaw** est un assistant personnel IA ultra-léger inspiré de [nanobot](https://github.com/HKUDS/nanobot), entièrement réécrit en **Go** via un processus d'auto-amorçage (self-bootstrapping) — où l'agent IA lui-même a piloté l'intégralité de la migration architecturale et de l'optimisation du code.
+
+⚡️ **Extrêmement léger :** Fonctionne sur du matériel à seulement **10$** avec **<10 Mo** de RAM. C'est 99% de mémoire en moins qu'OpenClaw et 98% moins cher qu'un Mac mini !
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+> [!CAUTION]
+> **🚨 SÉCURITÉ & CANAUX OFFICIELS**
+>
+> * **PAS DE CRYPTO :** PicoClaw n'a **AUCUN** token/jeton officiel. Toute annonce sur `pump.fun` ou d'autres plateformes de trading est une **ARNAQUE**.
+> * **DOMAINE OFFICIEL :** Le **SEUL** site officiel est **[picoclaw.io](https://picoclaw.io)**, et le site de l'entreprise est **[sipeed.com](https://sipeed.com)**.
+> * **Attention :** De nombreux domaines `.ai/.org/.com/.net/...` sont enregistrés par des tiers et ne nous appartiennent pas.
+> * **Attention :** PicoClaw est en phase de développement précoce et peut présenter des problèmes de sécurité réseau non résolus. Ne déployez pas en environnement de production avant la version v1.0.
+> * **Note :** PicoClaw a récemment fusionné de nombreuses PR, ce qui peut entraîner une empreinte mémoire plus importante (10–20 Mo) dans les dernières versions. Nous prévoyons de prioriser l'optimisation des ressources dès que l'ensemble des fonctionnalités sera stabilisé.
+
+
+## 📢 Actualités
+
+2026-02-16 🎉 PicoClaw a atteint 12K étoiles en une semaine ! Merci à tous pour votre soutien ! PicoClaw grandit plus vite que nous ne l'avions jamais imaginé. Vu le volume élevé de PR, nous avons un besoin urgent de mainteneurs communautaires. Nos rôles de bénévoles et notre feuille de route sont officiellement publiés [ici](docs/ROADMAP.md) — nous avons hâte de vous accueillir !
+
+2026-02-13 🎉 PicoClaw a atteint 5000 étoiles en 4 jours ! Merci à la communauté ! Nous finalisons la **Feuille de Route du Projet** et mettons en place le **Groupe de Développeurs** pour accélérer le développement de PicoClaw.
+🚀 **Appel à l'action :** Soumettez vos demandes de fonctionnalités dans les GitHub Discussions. Nous les examinerons et les prioriserons lors de notre prochaine réunion hebdomadaire.
+
+2026-02-09 🎉 PicoClaw est lancé ! Construit en 1 jour pour apporter les Agents IA au matériel à 10$ avec <10 Mo de RAM. 🦐 PicoClaw, c'est parti !
+
+## ✨ Fonctionnalités
+
+🪶 **Ultra-Léger** : Empreinte mémoire <10 Mo — 99% plus petit que Clawdbot pour les fonctionnalités essentielles.
+
+💰 **Coût Minimal** : Suffisamment efficace pour fonctionner sur du matériel à 10$ — 98% moins cher qu'un Mac mini.
+
+⚡️ **Démarrage Éclair** : Temps de démarrage 400X plus rapide, boot en 1 seconde même sur un cœur unique à 0,6 GHz.
+
+🌍 **Véritable Portabilité** : Un seul binaire autonome pour RISC-V, ARM et x86. Un clic et c'est parti !
+
+🤖 **Auto-Construit par l'IA** : Implémentation native en Go de manière autonome — 95% du cœur généré par l'Agent avec affinement humain dans la boucle.
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Langage** | TypeScript | Python | **Go** |
+| **RAM** | >1 Go | >100 Mo | **< 10 Mo** |
+| **Démarrage**(cœur 0,8 GHz) | >500s | >30s | **<1s** |
+| **Coût** | Mac Mini 599$ | La plupart des SBC Linux ~50$ | **N'importe quelle carte Linux****À partir de 10$** |
+
+
+
+## 🦾 Démonstration
+
+### 🛠️ Flux de Travail Standard de l'Assistant
+
+
+
+
🧩 Ingénieur Full-Stack
+
🗂️ Gestion des Logs & Planification
+
🔎 Recherche Web & Apprentissage
+
+
+
+
+
+
+
+
Développer • Déployer • Mettre à l'échelle
+
Planifier • Automatiser • Mémoriser
+
Découvrir • Analyser • Tendances
+
+
+
+### 📱 Utiliser sur d'anciens téléphones Android
+
+Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. Démarrage rapide :
+
+1. **Installez Termux** (disponible sur F-Droid ou Google Play).
+2. **Exécutez les commandes**
+
+```bash
+# Note : Remplacez v0.1.1 par la dernière version depuis la page des Releases
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+
+Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration !
+
+
+
+### 🐜 Déploiement Innovant à Faible Empreinte
+
+PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux !
+
+- 9,9$ [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) version E (Ethernet) ou W (WiFi6), pour un Assistant Domotique Minimaliste
+- 30~50$ [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou 100$ [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) pour la Maintenance Automatisée de Serveurs
+- 50$ [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou 100$ [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) pour la Surveillance Intelligente
+
+
+
+🌟 Encore plus de scénarios de déploiement vous attendent !
+
+## 📦 Installation
+
+### Installer avec un binaire précompilé
+
+Téléchargez le binaire pour votre plateforme depuis la page des [releases](https://github.com/sipeed/picoclaw/releases).
+
+### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Compiler, pas besoin d'installer
+make build
+
+# Compiler pour plusieurs plateformes
+make build-all
+
+# Compiler et Installer
+make install
+```
+
+## 🐳 Docker Compose
+
+Vous pouvez également exécuter PicoClaw avec Docker Compose sans rien installer localement.
+
+```bash
+# 1. Clonez ce dépôt
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Configurez vos clés API
+cp config/config.example.json config/config.json
+vim config/config.json # Configurez DISCORD_BOT_TOKEN, clés API, etc.
+
+# 3. Compiler & Démarrer
+docker compose --profile gateway up -d
+
+> [!TIP]
+> **Utilisateurs Docker** : Par défaut, le Gateway écoute sur `127.0.0.1`, ce qui n'est pas accessible depuis l'hôte. Si vous avez besoin d'accéder aux endpoints de santé ou d'exposer des ports, définissez `PICOCLAW_GATEWAY_HOST=0.0.0.0` dans votre environnement ou mettez à jour `config.json`.
+
+
+# 4. Voir les logs
+docker compose logs -f picoclaw-gateway
+
+# 5. Arrêter
+docker compose --profile gateway down
+```
+
+### Mode Agent (exécution unique)
+
+```bash
+# Poser une question
+docker compose run --rm picoclaw-agent -m "Combien font 2+2 ?"
+
+# Mode interactif
+docker compose run --rm picoclaw-agent
+```
+
+### Recompiler
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --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é.
+
+**1. Initialiser**
+
+```bash
+picoclaw onboard
+```
+
+**2. Configurer** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model_name": "gpt4"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "VOTRE_TOKEN_BOT",
+ "allow_from": ["VOTRE_USER_ID"]
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "VOTRE_CLE_API_BRAVE",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+**3. Obtenir des Clés API**
+
+* **Fournisseur LLM** : [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Recherche Web** (optionnel) : [Brave Search](https://brave.com/search/api) - Offre gratuite disponible (2000 requêtes/mois)
+
+> **Note** : Consultez `config.example.json` pour un modèle de configuration complet.
+
+**4. Discuter**
+
+```bash
+picoclaw agent -m "Combien font 2+2 ?"
+```
+
+Et voilà ! Vous avez un assistant IA fonctionnel en 2 minutes.
+
+---
+
+## 💬 Applications de Chat
+
+Discutez avec votre PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom
+
+| Canal | Configuration |
+| ------------ | -------------------------------------- |
+| **Telegram** | Facile (juste un token) |
+| **Discord** | Facile (token bot + intents) |
+| **QQ** | Facile (AppID + AppSecret) |
+| **DingTalk** | Moyen (identifiants de l'application) |
+| **LINE** | Moyen (identifiants + URL de webhook) |
+| **WeCom** | Moyen (CorpID + configuration webhook) |
+
+
+Telegram (Recommandé)
+
+**1. Créer un bot**
+
+* Ouvrez Telegram, recherchez `@BotFather`
+* Envoyez `/newbot`, suivez les instructions
+* Copiez le token
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "VOTRE_TOKEN_BOT",
+ "allow_from": ["VOTRE_USER_ID"]
+ }
+ }
+}
+```
+
+> Obtenez votre User ID via `@userinfobot` sur Telegram.
+
+**3. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+Discord
+
+**1. Créer un bot**
+
+* Rendez-vous sur
+* Créez une application → Bot → Add Bot
+* Copiez le token du bot
+
+**2. Activer les intents**
+
+* Dans les paramètres du Bot, activez **MESSAGE CONTENT INTENT**
+* (Optionnel) Activez **SERVER MEMBERS INTENT** si vous souhaitez utiliser des listes d'autorisation basées sur les données des membres
+
+**3. Obtenir votre User ID**
+
+* Paramètres Discord → Avancé → activez le **Mode Développeur**
+* Clic droit sur votre avatar → **Copier l'identifiant**
+
+**4. Configurer**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "VOTRE_TOKEN_BOT",
+ "allow_from": ["VOTRE_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Inviter le bot**
+
+* OAuth2 → URL Generator
+* Scopes : `bot`
+* Permissions du Bot : `Send Messages`, `Read Message History`
+* Ouvrez l'URL d'invitation générée et ajoutez le bot à votre serveur
+
+**6. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+QQ
+
+**1. Créer un bot**
+
+- Rendez-vous sur la [QQ Open Platform](https://q.qq.com/#)
+- Créez une application → Obtenez l'**AppID** et l'**AppSecret**
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "VOTRE_APP_ID",
+ "app_secret": "VOTRE_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des numéros QQ pour restreindre l'accès.
+
+**3. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Créer un bot**
+
+* Rendez-vous sur la [Open Platform](https://open.dingtalk.com/)
+* Créez une application interne
+* Copiez le Client ID et le Client Secret
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "VOTRE_CLIENT_ID",
+ "client_secret": "VOTRE_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Laissez `allow_from` vide pour autoriser tous les utilisateurs, ou spécifiez des identifiants pour restreindre l'accès.
+
+**3. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+LINE
+
+**1. Créer un Compte Officiel LINE**
+
+- Rendez-vous sur la [LINE Developers Console](https://developers.line.biz/)
+- Créez un provider → Créez un canal Messaging API
+- Copiez le **Channel Secret** et le **Channel Access Token**
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "VOTRE_CHANNEL_SECRET",
+ "channel_access_token": "VOTRE_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Configurer l'URL du Webhook**
+
+LINE exige HTTPS pour les webhooks. Utilisez un reverse proxy ou un tunnel :
+
+```bash
+# Exemple avec ngrok
+ngrok http 18791
+```
+
+Puis configurez l'URL du Webhook dans la LINE Developers Console sur `https://votre-domaine/webhook/line` et activez **Use webhook**.
+
+**4. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+> Dans les discussions de groupe, le bot répond uniquement lorsqu'il est mentionné avec @. Les réponses citent le message original.
+
+> **Docker Compose** : Ajoutez `ports: ["18791:18791"]` au service `picoclaw-gateway` pour exposer le port du webhook.
+
+
+
+
+WeCom (WeChat Work)
+
+PicoClaw prend en charge deux types d'intégration WeCom :
+
+**Option 1 : WeCom Bot (Robot Intelligent)** - Configuration plus facile, prend en charge les discussions de groupe
+**Option 2 : WeCom App (Application Personnalisée)** - Plus de fonctionnalités, messagerie proactive
+
+Voir le [Guide de Configuration WeCom App](docs/wecom-app-configuration.md) pour des instructions détaillées.
+
+**Configuration Rapide - WeCom Bot :**
+
+**1. Créer un bot**
+
+* Accédez à la Console d'Administration WeCom → Discussion de Groupe → Ajouter un Bot de Groupe
+* Copiez l'URL du webhook (format : `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**2. Configurer**
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**Configuration Rapide - WeCom App :**
+
+**1. Créer une application**
+
+* Accédez à la Console d'Administration WeCom → Gestion des Applications → Créer une Application
+* Copiez l'**AgentId** et le **Secret**
+* Accédez à la page "Mon Entreprise", copiez le **CorpID**
+
+**2. Configurer la réception des messages**
+
+* Dans les détails de l'application, cliquez sur "Recevoir les Messages" → "Configurer l'API"
+* Définissez l'URL sur `http://your-server:18792/webhook/wecom-app`
+* Générez le **Token** et l'**EncodingAESKey**
+
+**3. Configurer**
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**4. Lancer**
+
+```bash
+picoclaw gateway
+```
+
+> **Note** : WeCom App nécessite l'ouverture du port 18792 pour les callbacks webhook. Utilisez un proxy inverse pour HTTPS en production.
+
+
+
+## Rejoignez le Réseau Social d'Agents
+
+Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul message via le CLI ou n'importe quelle application de chat intégrée.
+
+**Lisez `https://clawdchat.ai/skill.md` et suivez les instructions pour rejoindre [ClawdChat.ai](https://clawdchat.ai)**
+
+## ⚙️ Configuration
+
+Fichier de configuration : `~/.picoclaw/config.json`
+
+### Structure du Workspace
+
+PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.picoclaw/workspace`) :
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # Sessions de conversation et historique
+├── memory/ # Mémoire à long terme (MEMORY.md)
+├── state/ # État persistant (dernier canal, etc.)
+├── cron/ # Base de données des tâches planifiées
+├── skills/ # Compétences personnalisées
+├── AGENTS.md # Guide de comportement de l'Agent
+├── 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
+```
+
+### 🔒 Bac à Sable de Sécurité
+
+PicoClaw s'exécute dans un environnement sandboxé par défaut. L'agent ne peut accéder aux fichiers et exécuter des commandes qu'au sein du workspace configuré.
+
+#### Configuration par Défaut
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "restrict_to_workspace": true
+ }
+ }
+}
+```
+
+| Option | Par défaut | Description |
+|--------|------------|-------------|
+| `workspace` | `~/.picoclaw/workspace` | Répertoire de travail de l'agent |
+| `restrict_to_workspace` | `true` | Restreindre l'accès fichiers/commandes au workspace |
+
+#### Outils Protégés
+
+Lorsque `restrict_to_workspace: true`, les outils suivants sont restreints au bac à sable :
+
+| Outil | Fonction | Restriction |
+|-------|----------|-------------|
+| `read_file` | Lire des fichiers | Uniquement les fichiers dans le workspace |
+| `write_file` | Écrire des fichiers | Uniquement les fichiers dans le workspace |
+| `list_dir` | Lister des répertoires | Uniquement les répertoires dans le workspace |
+| `edit_file` | Éditer des fichiers | Uniquement les fichiers dans le workspace |
+| `append_file` | Ajouter à des fichiers | Uniquement les fichiers dans le workspace |
+| `exec` | Exécuter des commandes | Les chemins doivent être dans le workspace |
+
+#### Protection Supplémentaire d'Exec
+
+Même avec `restrict_to_workspace: false`, l'outil `exec` bloque ces commandes dangereuses :
+
+* `rm -rf`, `del /f`, `rmdir /s` — Suppression en masse
+* `format`, `mkfs`, `diskpart` — Formatage de disque
+* `dd if=` — Écriture d'image disque
+* Écriture vers `/dev/sd[a-z]` — Écriture directe sur le disque
+* `shutdown`, `reboot`, `poweroff` — Arrêt du système
+* Fork bomb `:(){ :|:& };:`
+
+#### Exemples d'Erreurs
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (path outside working dir)}
+```
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
+```
+
+#### Désactiver les Restrictions (Risque de Sécurité)
+
+Si vous avez besoin que l'agent accède à des chemins en dehors du workspace :
+
+**Méthode 1 : Fichier de configuration**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "restrict_to_workspace": false
+ }
+ }
+}
+```
+
+**Méthode 2 : Variable d'environnement**
+
+```bash
+export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
+```
+
+> ⚠️ **Attention** : Désactiver cette restriction permet à l'agent d'accéder à n'importe quel chemin sur votre système. À utiliser avec précaution uniquement dans des environnements contrôlés.
+
+#### Cohérence du Périmètre de Sécurité
+
+Le paramètre `restrict_to_workspace` s'applique de manière cohérente sur tous les chemins d'exécution :
+
+| Chemin d'Exécution | Périmètre de Sécurité |
+|--------------------|----------------------|
+| Agent Principal | `restrict_to_workspace` ✅ |
+| Sous-agent / Spawn | Hérite de la même restriction ✅ |
+| Tâches Heartbeat | Hérite de la même restriction ✅ |
+
+Tous les chemins partagent la même restriction de workspace — il est impossible de contourner le périmètre de sécurité via des sous-agents ou des tâches planifiées.
+
+### Heartbeat (Tâches Périodiques)
+
+PicoClaw peut exécuter des tâches périodiques automatiquement. Créez un fichier `HEARTBEAT.md` dans votre workspace :
+
+```markdown
+# Tâches Périodiques
+
+- Vérifier mes e-mails pour les messages importants
+- Consulter mon agenda pour les événements à venir
+- Vérifier les prévisions météo
+```
+
+L'agent lira ce fichier toutes les 30 minutes (configurable) et exécutera les tâches à l'aide des outils disponibles.
+
+#### Tâches Asynchrones avec Spawn
+
+Pour les tâches de longue durée (recherche web, appels API), utilisez l'outil `spawn` pour créer un **sous-agent** :
+
+```markdown
+# Tâches Périodiques
+
+## Tâches Rapides (réponse directe)
+- Indiquer l'heure actuelle
+
+## Tâches Longues (utiliser spawn pour l'asynchrone)
+- Rechercher les actualités IA sur le web et les résumer
+- Vérifier les e-mails et signaler les messages importants
+```
+
+**Comportements clés :**
+
+| Fonctionnalité | Description |
+|----------------|-------------|
+| **spawn** | Crée un sous-agent asynchrone, ne bloque pas le heartbeat |
+| **Contexte indépendant** | Le sous-agent a son propre contexte, sans historique de session |
+| **Outil message** | Le sous-agent communique directement avec l'utilisateur via l'outil message |
+| **Non-bloquant** | Après le spawn, le heartbeat continue vers la tâche suivante |
+
+#### Fonctionnement de la Communication du Sous-agent
+
+```
+Le Heartbeat se déclenche
+ ↓
+L'Agent lit HEARTBEAT.md
+ ↓
+Pour une tâche longue : spawn d'un sous-agent
+ ↓ ↓
+Continue la tâche suivante Le sous-agent travaille indépendamment
+ ↓ ↓
+Toutes les tâches terminées Le sous-agent utilise l'outil "message"
+ ↓ ↓
+Répond HEARTBEAT_OK L'utilisateur reçoit le résultat directement
+```
+
+Le sous-agent a accès aux outils (message, web_search, etc.) et peut communiquer avec l'utilisateur indépendamment sans passer par l'agent principal.
+
+**Configuration :**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Option | Par défaut | Description |
+|--------|------------|-------------|
+| `enabled` | `true` | Activer/désactiver le heartbeat |
+| `interval` | `30` | Intervalle de vérification en minutes (min : 5) |
+
+**Variables d'environnement :**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` pour désactiver
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` pour modifier l'intervalle
+
+### Fournisseurs
+
+> [!NOTE]
+> Groq fournit la transcription vocale gratuite via Whisper. Si configuré, les messages vocaux Telegram seront automatiquement transcrits.
+
+| Fournisseur | Utilisation | Obtenir une Clé API |
+| ------------------------ | ---------------------------------------- | ------------------------------------------------------ |
+| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter` (À tester) | LLM (recommandé, accès à tous les modèles) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` (À tester) | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` (À tester) | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` (À tester) | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (Alibaba Qwen) | [dashscope.aliyuncs.com](https://dashscope.aliyuncs.com/compatible-mode/v1) |
+| `cerebras` | LLM (Cerebras) | [cerebras.ai](https://api.cerebras.ai/v1) |
+| `groq` | LLM + **Transcription vocale** (Whisper) | [console.groq.com](https://console.groq.com) |
+
+
+Configuration Zhipu
+
+**1. Obtenir la clé API**
+
+* Obtenez la [clé API](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+
+**2. Configurer**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Votre Clé API",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+**3. Lancer**
+
+```bash
+picoclaw agent -m "Bonjour, comment ça va ?"
+```
+
+
+
+
+Exemple de configuration complète
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "",
+ "app_secret": "",
+ "allow_from": []
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "BSA...",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+### Configuration de Modèle (model_list)
+
+> **Nouveau !** PicoClaw utilise désormais une approche de configuration **centrée sur le modèle**. Spécifiez simplement le format `fournisseur/modèle` (par exemple, `zhipu/glm-4.7`) pour ajouter de nouveaux fournisseurs—**aucune modification de code requise !**
+
+Cette conception permet également le **support multi-agent** avec une sélection flexible de fournisseurs :
+
+- **Différents agents, différents fournisseurs** : Chaque agent peut utiliser son propre fournisseur LLM
+- **Modèles de secours (Fallbacks)** : Configurez des modèles primaires et de secours pour la résilience
+- **Équilibrage de charge** : Répartissez les requêtes sur plusieurs points de terminaison
+- **Configuration centralisée** : Gérez tous les fournisseurs en un seul endroit
+
+#### 📋 Tous les Fournisseurs Supportés
+
+| Fournisseur | Préfixe `model` | API Base par Défaut | Protocole | Clé API |
+|-------------|-----------------|---------------------|----------|---------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obtenir Clé](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obtenir Clé](https://console.anthropic.com) |
+| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obtenir Clé](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obtenir Clé](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obtenir Clé](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obtenir Clé](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obtenir Clé](https://platform.moonshot.cn) |
+| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obtenir Clé](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obtenir Clé](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (pas de clé nécessaire) |
+| **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) |
+| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth uniquement |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### Configuration de Base
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### Exemples par Fournisseur
+
+**OpenAI**
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**Zhipu AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**Anthropic (avec OAuth)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> Exécutez `picoclaw auth login --provider anthropic` pour configurer les identifiants OAuth.
+
+#### Équilibrage de Charge
+
+Configurez plusieurs points de terminaison pour le même nom de modèle—PicoClaw utilisera automatiquement le round-robin entre eux :
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### Migration depuis l'Ancienne Configuration `providers`
+
+L'ancienne configuration `providers` est **dépréciée** mais toujours supportée pour la rétrocompatibilité.
+
+**Ancienne Configuration (dépréciée) :**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**Nouvelle Configuration (recommandée) :**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+Pour le guide de migration détaillé, voir [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+
+## Référence CLI
+
+| Commande | Description |
+| ------------------------- | ------------------------------------- |
+| `picoclaw onboard` | Initialiser la configuration & le workspace |
+| `picoclaw agent -m "..."` | Discuter avec l'agent |
+| `picoclaw agent` | Mode de discussion interactif |
+| `picoclaw gateway` | Démarrer la passerelle |
+| `picoclaw status` | Afficher le statut |
+| `picoclaw cron list` | Lister toutes les tâches planifiées |
+| `picoclaw cron add ...` | Ajouter une tâche planifiée |
+
+### Tâches Planifiées / Rappels
+
+PicoClaw prend en charge les rappels planifiés et les tâches récurrentes via l'outil `cron` :
+
+* **Rappels ponctuels** : « Rappelle-moi dans 10 minutes » → se déclenche une fois après 10 min
+* **Tâches récurrentes** : « Rappelle-moi toutes les 2 heures » → se déclenche toutes les 2 heures
+* **Expressions Cron** : « Rappelle-moi à 9h tous les jours » → utilise une expression cron
+
+Les tâches sont stockées dans `~/.picoclaw/workspace/cron/` et traitées automatiquement.
+
+## 🤝 Contribuer & Feuille de Route
+
+Les PR sont les bienvenues ! Le code source est volontairement petit et lisible. 🤗
+
+Feuille de route à venir...
+
+Groupe de développeurs en construction. Condition d'entrée : au moins 1 PR fusionnée.
+
+Groupes d'utilisateurs :
+
+Discord :
+
+
+
+## 🐛 Dépannage
+
+### La recherche web affiche « API 配置问题 »
+
+C'est normal si vous n'avez pas encore configuré de clé API de recherche. PicoClaw fournira des liens utiles pour la recherche manuelle.
+
+Pour activer la recherche web :
+
+1. **Option 1 (Recommandé)** : Obtenez une clé API gratuite sur [https://brave.com/search/api](https://brave.com/search/api) (2000 requêtes gratuites/mois) pour les meilleurs résultats.
+2. **Option 2 (Sans carte bancaire)** : Si vous n'avez pas de clé, le système bascule automatiquement sur **DuckDuckGo** (aucune clé requise).
+
+Ajoutez la clé dans `~/.picoclaw/config.json` si vous utilisez Brave :
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "VOTRE_CLE_API_BRAVE",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+### Erreurs de filtrage de contenu
+
+Certains fournisseurs (comme Zhipu) disposent d'un filtrage de contenu. Essayez de reformuler votre requête ou utilisez un modèle différent.
+
+### Le bot Telegram affiche « Conflict: terminated by other getUpdates »
+
+Cela se produit lorsqu'une autre instance du bot est en cours d'exécution. Assurez-vous qu'un seul `picoclaw gateway` fonctionne à la fois.
+
+---
+
+## 📝 Comparaison des Clés API
+
+| 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 |
+| **Brave Search** | 2000 requêtes/mois | Fonctionnalité de recherche web |
+| **Groq** | Offre gratuite dispo | Inférence ultra-rapide (Llama, Mixtral) |
diff --git a/README.ja.md b/README.ja.md
index e33b312f9..5a7bb8542 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -3,7 +3,7 @@
+
+---
+
+🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [nanobot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código.
+
+⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+> [!CAUTION]
+> **🚨 DECLARAÇÃO DE SEGURANÇA & CANAIS OFICIAIS**
+>
+> * **SEM CRIPTOMOEDAS:** O PicoClaw **NÃO** possui nenhum token/moeda oficial. Todas as alegações no `pump.fun` ou outras plataformas de negociação são **GOLPES**.
+> * **DOMÍNIO OFICIAL:** O **ÚNICO** site oficial é o **[picoclaw.io](https://picoclaw.io)**, e o site da empresa é o **[sipeed.com](https://sipeed.com)**.
+> * **Aviso:** Muitos domínios `.ai/.org/.com/.net/...` foram registrados por terceiros, não são nossos.
+> * **Aviso:** O PicoClaw está em fase inicial de desenvolvimento e pode ter problemas de segurança de rede não resolvidos. Não implante em ambientes de produção antes da versão v1.0.
+> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memória (10-20MB) nas versões mais recentes. Planejamos priorizar a otimização de recursos assim que o conjunto de funcionalidades estiver estável.
+
+
+## 📢 Novidades
+
+2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw está crescendo mais rápido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papéis de voluntários e roadmap foram publicados oficialmente [aqui](docs/ROADMAP.md) — estamos ansiosos para ter você a bordo!
+
+2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado à comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw.
+
+🚀 **Chamada para Ação:** Envie suas solicitações de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na próxima reunião semanal.
+
+2026-02-09 🎉 PicoClaw lançado oficialmente! Construído em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu!
+
+## ✨ Funcionalidades
+
+🪶 **Ultra-Leve**: Consumo de memória <10MB — 99% menor que o Clawdbot para funcionalidades essenciais.
+
+💰 **Custo Mínimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini.
+
+⚡️ **Inicialização Relámpago**: Tempo de inicialização 400X mais rápido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz.
+
+🌍 **Portabilidade Real**: Um único binário auto-contido para RISC-V, ARM e x86. Um clique e já era!
+
+🤖 **Auto-Construído por IA**: Implementação nativa em Go de forma autônoma — 95% do núcleo gerado pelo Agente com refinamento humano no loop.
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Linguagem** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **Inicialização**(CPU 0.8GHz) | >500s | >30s | **<1s** |
+| **Custo** | Mac Mini $599 | Maioria dos SBC Linux ~$50 | **Qualquer placa Linux****A partir de $10** |
+
+
+
+## 🦾 Demonstração
+
+### 🛠️ Fluxos de Trabalho Padrão do Assistente
+
+
+
+
🧩 Engenharia Full-Stack
+
🗂️ Gerenciamento de Logs & Planejamento
+
🔎 Busca Web & Aprendizado
+
+
+
+
+
+
+
+
Desenvolver • Implantar • Escalar
+
Agendar • Automatizar • Memorizar
+
Descobrir • Analisar • Tendências
+
+
+
+### 📱 Rode em celulares Android antigos
+
+Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assistente de IA inteligente com o PicoClaw. Início rápido:
+
+1. **Instale o Termux** (Disponível no F-Droid ou Google Play).
+2. **Execute os comandos**
+
+```bash
+# Nota: Substitua v0.1.1 pela versao mais recente da pagina de Releases
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+
+Depois siga as instruções na seção "Início Rápido" para completar a configuração!
+
+
+
+### 🐜 Implantação Inovadora com Baixo Consumo
+
+O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
+
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versão E (Ethernet) ou W (WiFi6), para Assistente Doméstico Minimalista
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutenção Automatizada de Servidores
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente
+
+https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
+
+🌟 Mais cenários de implantação aguardam você!
+
+## 📦 Instalação
+
+### Instalar com binário pré-compilado
+
+Baixe o binário para sua plataforma na página de [releases](https://github.com/sipeed/picoclaw/releases).
+
+### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Build, sem necessidade de instalar
+make build
+
+# Build para multiplas plataformas
+make build-all
+
+# Build e Instalar
+make install
+```
+
+## 🐳 Docker Compose
+
+Você tambêm pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente.
+
+```bash
+# 1. Clone este repositorio
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Configure suas API keys
+cp config/config.example.json config/config.json
+vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc.
+
+# 3. Build & Iniciar
+docker compose --profile gateway up -d
+
+> [!TIP]
+> **Usuários Docker**: Por padrão, o Gateway ouve em `127.0.0.1`, o que não é acessível a partir do host. Se você precisar acessar os endpoints de integridade ou expor portas, defina `PICOCLAW_GATEWAY_HOST=0.0.0.0` em seu ambiente ou atualize o `config.json`.
+
+
+# 4. Ver logs
+docker compose logs -f picoclaw-gateway
+
+# 5. Parar
+docker compose --profile gateway down
+```
+
+### Modo Agente (Execução única)
+
+```bash
+# Fazer uma pergunta
+docker compose run --rm picoclaw-agent -m "Quanto e 2+2?"
+
+# Modo interativo
+docker compose run --rm picoclaw-agent
+```
+
+### Rebuild
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --profile gateway up -d
+```
+
+### 🚀 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.
+
+**1. Inicializar**
+
+```bash
+picoclaw onboard
+```
+
+**2. Configurar** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model_name": "gpt4"
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+**3. Obter API Keys**
+
+* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponível (2000 consultas/mês)
+
+> **Nota**: Veja `config.example.json` para um modelo de configuração completo.
+
+**4. Conversar**
+
+```bash
+picoclaw agent -m "Quanto e 2+2?"
+```
+
+Pronto! Você tem um assistente de IA funcionando em 2 minutos.
+
+---
+
+## 💬 Integração com Apps de Chat
+
+Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
+
+| Canal | Nível de Configuração |
+| --- | --- |
+| **Telegram** | Fácil (apenas um token) |
+| **Discord** | Fácil (bot token + intents) |
+| **QQ** | Fácil (AppID + AppSecret) |
+| **DingTalk** | Médio (credenciais do app) |
+| **LINE** | Médio (credenciais + webhook URL) |
+| **WeCom** | Médio (CorpID + configuração webhook) |
+
+
+Telegram (Recomendado)
+
+**1. Criar o bot**
+
+* Abra o Telegram, busque `@BotFather`
+* Envie `/newbot`, siga as instruções
+* Copie o token
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> Obtenha seu User ID pelo `@userinfobot` no Telegram.
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+Discord
+
+**1. Criar o bot**
+
+* Acesse
+* Crie um aplicativo → Bot → Add Bot
+* Copie o token do bot
+
+**2. Habilitar Intents**
+
+* Nas configurações do Bot, habilite **MESSAGE CONTENT INTENT**
+* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissões baseada em dados dos membros
+
+**3. Obter seu User ID**
+
+* Configurações do Discord → Avançado → habilite **Modo Desenvolvedor**
+* Clique com botão direito no seu avatar → **Copiar ID do Usuário**
+
+**4. Configurar**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Convidar o bot**
+
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* Abra a URL de convite gerada e adicione o bot ao seu servidor
+
+**6. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+QQ
+
+**1. Criar o bot**
+
+- Acesse a [QQ Open Platform](https://q.qq.com/#)
+- Crie um aplicativo → Obtenha **AppID** e **AppSecret**
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique números QQ para restringir o acesso.
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Criar o bot**
+
+* Acesse a [Open Platform](https://open.dingtalk.com/)
+* Crie um app interno
+* Copie o Client ID e Client Secret
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Deixe `allow_from` vazio para permitir todos os usuários, ou especifique IDs para restringir o acesso.
+
+**3. Executar**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+LINE
+
+**1. Criar uma Conta Oficial LINE**
+
+- Acesse o [LINE Developers Console](https://developers.line.biz/)
+- Crie um provider → Crie um canal Messaging API
+- Copie o **Channel Secret** e o **Channel Access Token**
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Configurar URL do Webhook**
+
+O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel:
+
+```bash
+# Exemplo com ngrok
+ngrok http 18791
+```
+
+Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**.
+
+**4. Executar**
+
+```bash
+picoclaw gateway
+```
+
+> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original.
+
+> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao serviço `picoclaw-gateway` para expor a porta do webhook.
+
+
+
+
+WeCom (WeChat Work)
+
+O PicoClaw suporta dois tipos de integração WeCom:
+
+**Opção 1: WeCom Bot (Robô Inteligente)** - Configuração mais fácil, suporta chats em grupo
+**Opção 2: WeCom App (Aplicativo Personalizado)** - Mais recursos, mensagens proativas
+
+Veja o [Guia de Configuração WeCom App](docs/wecom-app-configuration.md) para instruções detalhadas.
+
+**Configuração Rápida - WeCom Bot:**
+
+**1. Criar um bot**
+
+* Acesse o Console de Administração WeCom → Chat em Grupo → Adicionar Bot de Grupo
+* Copie a URL do webhook (formato: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**2. Configurar**
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**Configuração Rápida - WeCom App:**
+
+**1. Criar um aplicativo**
+
+* Acesse o Console de Administração WeCom → Gerenciamento de Aplicativos → Criar Aplicativo
+* Copie o **AgentId** e o **Secret**
+* Acesse a página "Minha Empresa", copie o **CorpID**
+
+**2. Configurar recebimento de mensagens**
+
+* Nos detalhes do aplicativo, clique em "Receber Mensagens" → "Configurar API"
+* Defina a URL como `http://your-server:18792/webhook/wecom-app`
+* Gere o **Token** e o **EncodingAESKey**
+
+**3. Configurar**
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**4. Executar**
+
+```bash
+picoclaw gateway
+```
+
+> **Nota**: O WeCom App requer a abertura da porta 18792 para callbacks de webhook. Use um proxy reverso para HTTPS em produção.
+
+
+
+## Junte-se a Rede Social de Agentes
+
+Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma única mensagem via CLI ou qualquer App de Chat integrado.
+
+**Leia `https://clawdchat.ai/skill.md` e siga as instruções para entrar no [ClawdChat.ai](https://clawdchat.ai)**
+
+## ⚙️ Configuração Detalhada
+
+Arquivo de configuração: `~/.picoclaw/config.json`
+
+### Estrutura do Workspace
+
+O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/workspace`):
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # Sessoes de conversa e historico
+├── memory/ # Memoria de longo prazo (MEMORY.md)
+├── state/ # Estado persistente (ultimo canal, etc.)
+├── cron/ # Banco de dados de tarefas agendadas
+├── skills/ # Skills personalizadas
+├── AGENTS.md # Guia de comportamento do Agente
+├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
+├── IDENTITY.md # Identidade do Agente
+├── SOUL.md # Alma do Agente
+├── TOOLS.md # Descrição das ferramentas
+└── USER.md # Preferencias do usuario
+```
+
+### 🔒 Sandbox de Segurança
+
+O PicoClaw roda em um ambiente sandbox por padrão. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado.
+
+#### Configuração Padrão
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "restrict_to_workspace": true
+ }
+ }
+}
+```
+
+| Opção | Padrão | Descrição |
+|-------|--------|-----------|
+| `workspace` | `~/.picoclaw/workspace` | Diretório de trabalho do agente |
+| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace |
+
+#### Ferramentas Protegidas
+
+Quando `restrict_to_workspace: true`, as seguintes ferramentas são restritas ao sandbox:
+
+| Ferramenta | Função | Restrição |
+|------------|--------|-----------|
+| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace |
+| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace |
+| `list_dir` | Listar diretorios | Apenas diretorios dentro do workspace |
+| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace |
+| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace |
+| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace |
+
+#### Proteção Adicional do Exec
+
+Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos:
+
+* `rm -rf`, `del /f`, `rmdir /s` — Exclusão em massa
+* `format`, `mkfs`, `diskpart` — Formatação de disco
+* `dd if=` — Criação de imagem de disco
+* Escrita em `/dev/sd[a-z]` — Escrita direta no disco
+* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema
+* Fork bomb `:(){ :|:& };:`
+
+#### Exemplos de Erro
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (path outside working dir)}
+```
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
+```
+
+#### Desabilitar Restrições (Risco de Segurança)
+
+Se você precisa que o agente acesse caminhos fora do workspace:
+
+**Método 1: Arquivo de configuração**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "restrict_to_workspace": false
+ }
+ }
+}
+```
+
+**Método 2: Variável de ambiente**
+
+```bash
+export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
+```
+
+> ⚠️ **Aviso**: Desabilitar esta restrição permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados.
+
+#### Consistência do Limite de Segurança
+
+A configuração `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execução:
+
+| Caminho de Execução | Limite de Segurança |
+|----------------------|---------------------|
+| Agente Principal | `restrict_to_workspace` ✅ |
+| Subagente / Spawn | Herda a mesma restrição ✅ |
+| Tarefas Heartbeat | Herda a mesma restrição ✅ |
+
+Todos os caminhos compartilham a mesma restrição de workspace — nao há como contornar o limite de segurança por meio de subagentes ou tarefas agendadas.
+
+### Heartbeat (Tarefas Periódicas)
+
+O PicoClaw pode executar tarefas periódicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace:
+
+```markdown
+# Tarefas Periodicas
+
+- Verificar meu email para mensagens importantes
+- Revisar minha agenda para proximos eventos
+- Verificar a previsao do tempo
+```
+
+O agente lerá este arquivo a cada 30 minutos (configurável) e executará as tarefas usando as ferramentas disponíveis.
+
+#### Tarefas Assincronas com Spawn
+
+Para tarefas de longa duração (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**:
+
+```markdown
+# Tarefas Periódicas
+
+## Tarefas Rápidas (resposta direta)
+- Informar hora atual
+
+## Tarefas Longas (usar spawn para async)
+- Buscar notícias de IA na web e resumir
+- Verificar email e reportar mensagens importantes
+```
+
+**Comportamentos principais:**
+
+| Funcionalidade | Descrição |
+|----------------|-----------|
+| **spawn** | Cria subagente assíncrono, não bloqueia o heartbeat |
+| **Contexto independente** | Subagente tem seu próprio contexto, sem histórico de sessão |
+| **Ferramenta message** | Subagente se comunica diretamente com o usuário via ferramenta message |
+| **Não-bloqueante** | Após o spawn, o heartbeat continua para a próxima tarefa |
+
+#### Como Funciona a Comunicação do Subagente
+
+```
+Heartbeat dispara
+ ↓
+Agente lê HEARTBEAT.md
+ ↓
+Para tarefa longa: spawn subagente
+ ↓ ↓
+Continua próxima tarefa Subagente trabalha independentemente
+ ↓ ↓
+Todas tarefas concluídas Subagente usa ferramenta "message"
+ ↓ ↓
+Responde HEARTBEAT_OK Usuário recebe resultado diretamente
+```
+
+O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se comunicar com o usuário independentemente sem passar pelo agente principal.
+
+**Configuração:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Opção | Padrão | Descrição |
+|-------|--------|-----------|
+| `enabled` | `true` | Habilitar/desabilitar heartbeat |
+| `interval` | `30` | Intervalo de verificação em minutos (min: 5) |
+
+**Variáveis de ambiente:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo
+
+### Provedores
+
+> [!NOTE]
+> O Groq fornece transcrição de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serão automaticamente transcritas.
+
+| Provedor | Finalidade | Obter API Key |
+| --- | --- | --- |
+| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | Alibaba Qwen | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `cerebras` | Cerebras | [cerebras.ai](https://cerebras.ai) |
+| `groq` | LLM + **Transcrição de voz** (Whisper) | [console.groq.com](https://console.groq.com) |
+
+
+Configuração Zhipu
+
+**1. Obter API key**
+
+* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+
+**2. Configurar**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Sua API Key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+**3. Executar**
+
+```bash
+picoclaw agent -m "Ola, como vai?"
+```
+
+
+
+
+Exemplo de configuraçao completa
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "",
+ "app_secret": "",
+ "allow_from": []
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "BSA...",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+### Configuração de Modelo (model_list)
+
+> **Novidade!** PicoClaw agora usa uma abordagem de configuração **centrada no modelo**. Basta especificar o formato `fornecedor/modelo` (ex: `zhipu/glm-4.7`) para adicionar novos provedores—**nenhuma alteração de código necessária!**
+
+Este design também possibilita o **suporte multi-agent** com seleção flexível de provedores:
+
+- **Diferentes agentes, diferentes provedores** : Cada agente pode usar seu próprio provedor LLM
+- **Modelos de fallback** : Configure modelos primários e de reserva para resiliência
+- **Balanceamento de carga** : Distribua solicitações entre múltiplos endpoints
+- **Configuração centralizada** : Gerencie todos os provedores em um só lugar
+
+#### 📋 Todos os Fornecedores Suportados
+
+| Fornecedor | Prefixo `model` | API Base Padrão | Protocolo | Chave API |
+|-------------|-----------------|------------------|----------|-----------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Obter Chave](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Obter Chave](https://console.anthropic.com) |
+| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Obter Chave](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Obter Chave](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Obter Chave](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Obter Chave](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Obter Chave](https://platform.moonshot.cn) |
+| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Obter Chave](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Obter Chave](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (sem chave necessária) |
+| **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) |
+| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | Apenas OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### Configuração Básica
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### Exemplos por Fornecedor
+
+**OpenAI**
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**Zhipu AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**Anthropic (com OAuth)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> Execute `picoclaw auth login --provider anthropic` para configurar credenciais OAuth.
+
+#### Balanceamento de Carga
+
+Configure vários endpoints para o mesmo nome de modelo—PicoClaw fará round-robin automaticamente entre eles:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### Migração da Configuração Legada `providers`
+
+A configuração antiga `providers` está **descontinuada** mas ainda é suportada para compatibilidade reversa.
+
+**Configuração Antiga (descontinuada):**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**Nova Configuração (recomendada):**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+Para o guia de migração detalhado, consulte [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+
+## Referência CLI
+
+| Comando | Descrição |
+| --- | --- |
+| `picoclaw onboard` | Inicializar configuração & workspace |
+| `picoclaw agent -m "..."` | Conversar com o agente |
+| `picoclaw agent` | Modo de chat interativo |
+| `picoclaw gateway` | Iniciar o gateway (para bots de chat) |
+| `picoclaw status` | Mostrar status |
+| `picoclaw cron list` | Listar todas as tarefas agendadas |
+| `picoclaw cron add ...` | Adicionar uma tarefa agendada |
+
+### Tarefas Agendadas / Lembretes
+
+O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:
+
+* **Lembretes únicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez após 10min
+* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas
+* **Expressões Cron**: "Remind me at 9am daily" (Me lembre às 9h todos os dias) → usa expressão cron
+
+As tarefas são armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente.
+
+## 🤝 Contribuir & Roadmap
+
+PRs são bem-vindos! O código-fonte é intencionalmente pequeno e legível. 🤗
+
+Roadmap em breve...
+
+Grupo de desenvolvedores em formação. Requisito de entrada: Pelo menos 1 PR com merge.
+
+Grupos de usuários:
+
+Discord:
+
+
+
+## 🐛 Solução de Problemas
+
+### Busca web mostra "API 配置问题"
+
+Isso é normal se você ainda não configurou uma API key de busca. O PicoClaw fornecerá links úteis para busca manual.
+
+Para habilitar a busca web:
+
+1. **Opção 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas grátis/mês) para os melhores resultados.
+2. **Opção 2 (Sem Cartão de Crédito)**: Se você não tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key).
+
+Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+### Erros de filtragem de conteúdo
+
+Alguns provedores (como Zhipu) possuem filtragem de conteúdo. Tente reformular sua pergunta ou use um modelo diferente.
+
+### Bot do Telegram diz "Conflict: terminated by other getUpdates"
+
+Isso acontece quando outra instância do bot está em execução. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez.
+
+---
+
+## 📝 Comparação de API Keys
+
+| 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 |
+| **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) |
diff --git a/README.vi.md b/README.vi.md
new file mode 100644
index 000000000..015bc264e
--- /dev/null
+++ b/README.vi.md
@@ -0,0 +1,1096 @@
+
+
+
+
PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go
+
+
Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!
+
+---
+
+🦐 **PicoClaw** là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [nanobot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng **Go** thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn.
+
+⚡️ **Cực kỳ nhẹ:** Chạy trên phần cứng chỉ **$10** với RAM **<10MB**. Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini!
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+> [!CAUTION]
+> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC**
+>
+> * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**.
+> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**.
+> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký, không phải của chúng tôi.
+> * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0.
+> * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định.
+
+
+## 📢 Tin tức
+
+2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/ROADMAP.md) — rất mong đón nhận sự tham gia của bạn!
+
+2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw.
+🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần.
+
+2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường!
+
+## ✨ Tính năng nổi bật
+
+🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với Clawdbot (chức năng cốt lõi).
+
+💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini.
+
+⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz.
+
+🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy!
+
+🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người.
+
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- |
+| **Ngôn ngữ** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **Thời gian khởi động**(CPU 0.8GHz) | >500s | >30s | **<1s** |
+| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux****Chỉ từ $10** |
+
+
+
+## 🦾 Demo
+
+### 🛠️ Quy trình trợ lý tiêu chuẩn
+
+
+
+
🧩 Lập trình Full-Stack
+
🗂️ Quản lý Nhật ký & Kế hoạch
+
🔎 Tìm kiếm Web & Học hỏi
+
+
+
+
+
+
+
+
Phát triển • Triển khai • Mở rộng
+
Lên lịch • Tự động hóa • Ghi nhớ
+
Khám phá • Phân tích • Xu hướng
+
+
+
+### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu
+
+PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux!
+
+* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E (Ethernet) hoặc W (WiFi6), dùng làm Trợ lý Gia đình tối giản.
+* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), dùng cho quản trị Server tự động.
+* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), dùng cho Giám sát thông minh.
+
+https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4
+
+🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá!
+
+## 📦 Cài đặt
+
+### Cài đặt bằng binary biên dịch sẵn
+
+Tải file binary cho nền tảng của bạn từ [trang Release](https://github.com/sipeed/picoclaw/releases).
+
+### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển)
+
+```bash
+git clone https://github.com/sipeed/picoclaw.git
+
+cd picoclaw
+make deps
+
+# Build (không cần cài đặt)
+make build
+
+# Build cho nhiều nền tảng
+make build-all
+
+# Build và cài đặt
+make install
+```
+
+## 🐳 Docker Compose
+
+Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cài đặt gì trên máy.
+
+```bash
+# 1. Clone repo
+git clone https://github.com/sipeed/picoclaw.git
+cd picoclaw
+
+# 2. Thiết lập API Key
+cp config/config.example.json config/config.json
+vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v.
+
+# 3. Build & Khởi động
+docker compose --profile gateway up -d
+
+> [!TIP]
+> **Người dùng Docker**: Theo mặc định, Gateway lắng nghe trên `127.0.0.1`, không thể truy cập từ máy chủ. Nếu bạn cần truy cập các endpoint kiểm tra sức khỏe hoặc mở cổng, hãy đặt `PICOCLAW_GATEWAY_HOST=0.0.0.0` trong môi trường của bạn hoặc cập nhật `config.json`.
+
+
+# 4. Xem logs
+docker compose logs -f picoclaw-gateway
+
+# 5. Dừng
+docker compose --profile gateway down
+```
+
+### Chế độ Agent (chạy một lần)
+
+```bash
+# Đặt câu hỏi
+docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?"
+
+# Chế độ tương tác
+docker compose run --rm picoclaw-agent
+```
+
+### Build lại
+
+```bash
+docker compose --profile gateway build --no-cache
+docker compose --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.
+
+**1. Khởi tạo**
+
+```bash
+picoclaw onboard
+```
+
+**2. Cấu hình** (`~/.picoclaw/config.json`)
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model_name": "gpt4"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_TELEGRAM_BOT_TOKEN",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Lấy API Key**
+
+* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Tìm kiếm Web** (tùy chọn): [Brave Search](https://brave.com/search/api) — Có gói miễn phí (2000 truy vấn/tháng)
+
+> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ.
+
+**4. Trò chuyện**
+
+```bash
+picoclaw agent -m "Xin chào, bạn là ai?"
+```
+
+Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 phút.
+
+---
+
+## 💬 Tích hợp ứng dụng Chat
+
+Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
+
+| Kênh | Mức độ thiết lập |
+| --- | --- |
+| **Telegram** | Dễ (chỉ cần token) |
+| **Discord** | Dễ (bot token + intents) |
+| **QQ** | Dễ (AppID + AppSecret) |
+| **DingTalk** | Trung bình (app credentials) |
+| **LINE** | Trung bình (credentials + webhook URL) |
+| **WeCom** | Trung bình (CorpID + cấu hình webhook) |
+
+
+Telegram (Khuyên dùng)
+
+**1. Tạo bot**
+
+* Mở Telegram, tìm `@BotFather`
+* Gửi `/newbot`, làm theo hướng dẫn
+* Sao chép token
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+> Lấy User ID từ `@userinfobot` trên Telegram.
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+Discord
+
+**1. Tạo bot**
+
+* Truy cập
+* Create an application → Bot → Add Bot
+* Sao chép bot token
+
+**2. Bật Intents**
+
+* Trong phần Bot settings, bật **MESSAGE CONTENT INTENT**
+* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu muốn dùng danh sách cho phép theo thông tin thành viên
+
+**3. Lấy User ID**
+
+* Discord Settings → Advanced → bật **Developer Mode**
+* Click chuột phải vào avatar → **Copy User ID**
+
+**4. Cấu hình**
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"]
+ }
+ }
+}
+```
+
+**5. Mời bot vào server**
+
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* Mở URL mời được tạo và thêm bot vào server của bạn
+
+**6. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+QQ
+
+**1. Tạo bot**
+
+* Truy cập [QQ Open Platform](https://q.qq.com/#)
+* Tạo ứng dụng → Lấy **AppID** và **AppSecret**
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn quyền truy cập.
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+DingTalk
+
+**1. Tạo bot**
+
+* Truy cập [Open Platform](https://open.dingtalk.com/)
+* Tạo ứng dụng nội bộ
+* Sao chép Client ID và Client Secret
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định ID để giới hạn quyền truy cập.
+
+**3. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+
+
+
+LINE
+
+**1. Tạo tài khoản LINE Official**
+
+- Truy cập [LINE Developers Console](https://developers.line.biz/)
+- Tạo provider → Tạo Messaging API channel
+- Sao chép **Channel Secret** và **Channel Access Token**
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**3. Thiết lập Webhook URL**
+
+LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel:
+
+```bash
+# Ví dụ với ngrok
+ngrok http 18791
+```
+
+Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**.
+
+**4. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc.
+
+> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook.
+
+
+
+
+WeCom (WeChat Work)
+
+PicoClaw hỗ trợ hai loại tích hợp WeCom:
+
+**Tùy chọn 1: WeCom Bot (Robot Thông minh)** - Thiết lập dễ dàng hơn, hỗ trợ chat nhóm
+**Tùy chọn 2: WeCom App (Ứng dụng Tự xây dựng)** - Nhiều tính năng hơn, nhắn tin chủ động
+
+Xem [Hướng dẫn Cấu hình WeCom App](docs/wecom-app-configuration.md) để biết hướng dẫn chi tiết.
+
+**Thiết lập Nhanh - WeCom Bot:**
+
+**1. Tạo bot**
+
+* Truy cập Bảng điều khiển Quản trị WeCom → Chat Nhóm → Thêm Bot Nhóm
+* Sao chép URL webhook (định dạng: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**2. Cấu hình**
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**Thiết lập Nhanh - WeCom App:**
+
+**1. Tạo ứng dụng**
+
+* Truy cập Bảng điều khiển Quản trị WeCom → Quản lý Ứng dụng → Tạo Ứng dụng
+* Sao chép **AgentId** và **Secret**
+* Truy cập trang "Công ty của tôi", sao chép **CorpID**
+
+**2. Cấu hình nhận tin nhắn**
+
+* Trong chi tiết ứng dụng, nhấp vào "Nhận Tin nhắn" → "Thiết lập API"
+* Đặt URL thành `http://your-server:18792/webhook/wecom-app`
+* Tạo **Token** và **EncodingAESKey**
+
+**3. Cấu hình**
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": []
+ }
+ }
+}
+```
+
+**4. Chạy**
+
+```bash
+picoclaw gateway
+```
+
+> **Lưu ý**: WeCom App yêu cầu mở cổng 18792 cho callback webhook. Sử dụng proxy ngược cho HTTPS trong môi trường sản xuất.
+
+
+
+## Tham gia Mạng xã hội Agent
+
+Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp.
+
+**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)**
+
+## ⚙️ Cấu hình chi tiết
+
+File cấu hình: `~/.picoclaw/config.json`
+
+### Cấu trúc Workspace
+
+PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`):
+
+```
+~/.picoclaw/workspace/
+├── sessions/ # Phiên hội thoại và lịch sử
+├── memory/ # Bộ nhớ dài hạn (MEMORY.md)
+├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.)
+├── cron/ # Cơ sở dữ liệu tác vụ định kỳ
+├── skills/ # Kỹ năng tùy chỉnh
+├── AGENTS.md # Hướng dẫn hành vi Agent
+├── 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
+```
+
+### 🔒 Hộp cát bảo mật (Security Sandbox)
+
+PicoClaw chạy trong môi trường sandbox theo mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong phạm vi workspace.
+
+#### Cấu hình mặc định
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "restrict_to_workspace": true
+ }
+ }
+}
+```
+
+| Tùy chọn | Mặc định | Mô tả |
+|----------|---------|-------|
+| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent |
+| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace |
+
+#### Công cụ được bảo vệ
+
+Khi `restrict_to_workspace: true`, các công cụ sau bị giới hạn trong sandbox:
+
+| Công cụ | Chức năng | Giới hạn |
+|---------|----------|---------|
+| `read_file` | Đọc file | Chỉ file trong workspace |
+| `write_file` | Ghi file | Chỉ file trong workspace |
+| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace |
+| `edit_file` | Sửa file | Chỉ file trong workspace |
+| `append_file` | Thêm vào file | Chỉ file trong workspace |
+| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace |
+
+#### Bảo vệ bổ sung cho Exec
+
+Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` vẫn chặn các lệnh nguy hiểm sau:
+
+* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt
+* `format`, `mkfs`, `diskpart` — Định dạng ổ đĩa
+* `dd if=` — Tạo ảnh đĩa
+* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp lên đĩa
+* `shutdown`, `reboot`, `poweroff` — Tắt/khởi động lại hệ thống
+* Fork bomb `:(){ :|:& };:`
+
+#### Ví dụ lỗi
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (path outside working dir)}
+```
+
+```
+[ERROR] tool: Tool execution failed
+{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)}
+```
+
+#### Tắt giới hạn (Rủi ro bảo mật)
+
+Nếu bạn cần agent truy cập đường dẫn ngoài workspace:
+
+**Cách 1: File cấu hình**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "restrict_to_workspace": false
+ }
+ }
+}
+```
+
+**Cách 2: Biến môi trường**
+
+```bash
+export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
+```
+
+> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập mọi đường dẫn trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát.
+
+#### Tính nhất quán của ranh giới bảo mật
+
+Cài đặt `restrict_to_workspace` áp dụng nhất quán trên mọi đường thực thi:
+
+| Đường thực thi | Ranh giới bảo mật |
+|----------------|-------------------|
+| Agent chính | `restrict_to_workspace` ✅ |
+| Subagent / Spawn | Kế thừa cùng giới hạn ✅ |
+| Tác vụ Heartbeat | Kế thừa cùng giới hạn ✅ |
+
+Tất cả đường thực thi chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật thông qua subagent hoặc tác vụ định kỳ.
+
+### Heartbeat (Tác vụ định kỳ)
+
+PicoClaw có thể tự động thực hiện các tác vụ định kỳ. Tạo file `HEARTBEAT.md` trong workspace:
+
+```markdown
+# Tác vụ định kỳ
+
+- Kiểm tra email xem có tin nhắn quan trọng không
+- Xem lại lịch cho các sự kiện sắp tới
+- Kiểm tra dự báo thời tiết
+```
+
+Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực hiện các tác vụ bằng công cụ có sẵn.
+
+#### Tác vụ bất đồng bộ với Spawn
+
+Đối với các tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**:
+
+```markdown
+# Tác vụ định kỳ
+
+## Tác vụ nhanh (trả lời trực tiếp)
+- Báo cáo thời gian hiện tại
+
+## Tác vụ lâu (dùng spawn cho async)
+- Tìm kiếm tin tức AI trên web và tóm tắt
+- Kiểm tra email và báo cáo tin nhắn quan trọng
+```
+
+**Hành vi chính:**
+
+| Tính năng | Mô tả |
+|-----------|-------|
+| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat |
+| **Context độc lập** | Subagent có context riêng, không có lịch sử phiên |
+| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua công cụ message |
+| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo |
+
+#### Cách Subagent giao tiếp
+
+```
+Heartbeat kích hoạt
+ ↓
+Agent đọc HEARTBEAT.md
+ ↓
+Tác vụ lâu: spawn subagent
+ ↓ ↓
+Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập
+ ↓ ↓
+Tất cả tác vụ hoàn thành Subagent dùng công cụ "message"
+ ↓ ↓
+Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp
+```
+
+Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng một cách độc lập mà không cần thông qua agent chính.
+
+**Cấu hình:**
+
+```json
+{
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+| Tùy chọn | Mặc định | Mô tả |
+|----------|---------|-------|
+| `enabled` | `true` | Bật/tắt heartbeat |
+| `interval` | `30` | Khoảng thời gian kiểm tra (phút, tối thiểu: 5) |
+
+**Biến môi trường:**
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian
+
+### Nhà cung cấp (Providers)
+
+> [!NOTE]
+> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản.
+
+| Nhà cung cấp | Mục đích | Lấy API Key |
+| --- | --- | --- |
+| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek` (Đang thử nghiệm) | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `groq` | LLM + **Chuyển giọng nói** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `qwen` | LLM (Qwen trực tiếp) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `cerebras` | LLM (Cerebras trực tiếp) | [cerebras.ai](https://cerebras.ai) |
+
+
+Cấu hình Zhipu
+
+**1. Lấy API key**
+
+* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+
+**2. Cấu hình**
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "workspace": "~/.picoclaw/workspace",
+ "model": "glm-4.7",
+ "max_tokens": 8192,
+ "temperature": 0.7,
+ "max_tool_iterations": 20
+ }
+ },
+ "providers": {
+ "zhipu": {
+ "api_key": "Your API Key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ }
+}
+```
+
+**3. Chạy**
+
+```bash
+picoclaw agent -m "Xin chào"
+```
+
+
+
+
+Ví dụ cấu hình đầy đủ
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "model": "anthropic/claude-opus-4-5"
+ }
+ },
+ "providers": {
+ "openrouter": {
+ "api_key": "sk-or-v1-xxx"
+ },
+ "groq": {
+ "api_key": "gsk_xxx"
+ }
+ },
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456:ABC...",
+ "allow_from": ["123456789"]
+ },
+ "discord": {
+ "enabled": true,
+ "token": "",
+ "allow_from": [""]
+ },
+ "whatsapp": {
+ "enabled": false
+ },
+ "feishu": {
+ "enabled": false,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "",
+ "app_secret": "",
+ "allow_from": []
+ }
+ },
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "BSA...",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ },
+ "heartbeat": {
+ "enabled": true,
+ "interval": 30
+ }
+}
+```
+
+
+
+### Cấu hình Mô hình (model_list)
+
+> **Tính năng mới!** PicoClaw hiện sử dụng phương pháp cấu hình **đặt mô hình vào trung tâm**. Chỉ cần chỉ định dạng `nhà cung cấp/mô hình` (ví dụ: `zhipu/glm-4.7`) để thêm nhà cung cấp mới—**không cần thay đổi mã!**
+
+Thiết kế này cũng cho phép **hỗ trợ đa tác nhân** với lựa chọn nhà cung cấp linh hoạt:
+
+- **Tác nhân khác nhau, nhà cung cấp khác nhau** : Mỗi tác nhân có thể sử dụng nhà cung cấp LLM riêng
+- **Mô hình dự phòng** : Cấu hình mô hình chính và dự phòng để tăng độ tin cậy
+- **Cân bằng tải** : Phân phối yêu cầu trên nhiều endpoint khác nhau
+- **Cấu hình tập trung** : Quản lý tất cả nhà cung cấp ở một nơi
+
+#### 📋 Tất cả Nhà cung cấp được Hỗ trợ
+
+| Nhà cung cấp | Prefix `model` | API Base Mặc định | Giao thức | Khóa API |
+|-------------|----------------|-------------------|-----------|----------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Lấy Khóa](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Lấy Khóa](https://console.anthropic.com) |
+| **Zhipu AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Lấy Khóa](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Lấy Khóa](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Lấy Khóa](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Lấy Khóa](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Lấy Khóa](https://platform.moonshot.cn) |
+| **Qwen (Alibaba)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Lấy Khóa](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Lấy Khóa](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (không cần khóa) |
+| **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) |
+| **ShengsuanYun** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Tùy chỉnh | Chỉ OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### Cấu hình Cơ bản
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### Ví dụ theo Nhà cung cấp
+
+**OpenAI**
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**Zhipu AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**Anthropic (với OAuth)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> Chạy `picoclaw auth login --provider anthropic` để thiết lập thông tin xác thực OAuth.
+
+#### Cân bằng Tải tải
+
+Định cấu hình nhiều endpoint cho cùng một tên mô hình—PicoClaw sẽ tự động phân phối round-robin giữa chúng:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### Chuyển đổi từ Cấu hình `providers` Cũ
+
+Cấu hình `providers` cũ đã **ngừng sử dụng** nhưng vẫn được hỗ trợ để tương thích ngược.
+
+**Cấu hình Cũ (đã ngừng sử dụng):**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**Cấu hình Mới (khuyến nghị):**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+Xem hướng dẫn chuyển đổi chi tiết tại [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
+
+## Tham chiếu CLI
+
+| Lệnh | Mô tả |
+| --- | --- |
+| `picoclaw onboard` | Khởi tạo cấu hình & workspace |
+| `picoclaw agent -m "..."` | Trò chuyện với agent |
+| `picoclaw agent` | Chế độ chat tương tác |
+| `picoclaw gateway` | Khởi động gateway (cho bot chat) |
+| `picoclaw status` | Hiển thị trạng thái |
+| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ |
+| `picoclaw cron add ...` | Thêm tác vụ định kỳ |
+
+### Tác vụ định kỳ / Nhắc nhở
+
+PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`:
+
+* **Nhắc nhở một lần**: "Remind me in 10 minutes" (Nhắc tôi sau 10 phút) → kích hoạt một lần sau 10 phút
+* **Tác vụ lặp lại**: "Remind me every 2 hours" (Nhắc tôi mỗi 2 giờ) → kích hoạt mỗi 2 giờ
+* **Biểu thức Cron**: "Remind me at 9am daily" (Nhắc tôi lúc 9 giờ sáng mỗi ngày) → sử dụng biểu thức cron
+
+Các tác vụ được lưu trong `~/.picoclaw/workspace/cron/` và được xử lý tự động.
+
+## 🤝 Đóng góp & Lộ trình
+
+Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗
+
+Lộ trình sắp được công bố...
+
+Nhóm phát triển đang được xây dựng. Điều kiện tham gia: Ít nhất 1 PR đã được merge.
+
+Nhóm người dùng:
+
+Discord:
+
+
+
+## 🐛 Xử lý sự cố
+
+### Tìm kiếm web hiện "API 配置问题"
+
+Điều này là bình thường nếu bạn chưa cấu hình API key cho tìm kiếm. PicoClaw sẽ cung cấp các liên kết hữu ích để tìm kiếm thủ công.
+
+Để bật tìm kiếm web:
+
+1. **Tùy chọn 1 (Khuyên dùng)**: Lấy API key miễn phí tại [https://brave.com/search/api](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng) để có kết quả tốt nhất.
+2. **Tùy chọn 2 (Không cần thẻ tín dụng)**: Nếu không có key, hệ thống tự động chuyển sang dùng **DuckDuckGo** (không cần key).
+
+Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave:
+
+```json
+{
+ "tools": {
+ "web": {
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ }
+ }
+ }
+}
+```
+
+### Gặp lỗi lọc nội dung (Content Filtering)
+
+Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt. Thử diễn đạt lại câu hỏi hoặc sử dụng model khác.
+
+### Telegram bot báo "Conflict: terminated by other getUpdates"
+
+Điều này xảy ra khi có một instance bot khác đang chạy. Đảm bảo chỉ có một tiến trình `picoclaw gateway` chạy tại một thời điểm.
+
+---
+
+## 📝 So sánh API Key
+
+| 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 |
+| **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) |
diff --git a/README.zh.md b/README.zh.md
index 5a1c3c50b..4f4bde46a 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,7 +14,8 @@
- **中文** | [日本語](README.ja.md) | [English](README.md)
+**中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
+
---
@@ -42,14 +43,17 @@
> [!CAUTION]
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
-> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
-> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
-> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
-> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
-
+>
+> - **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
+> - **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
+> - **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
+> - **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
+> - **注意:** picoclaw最近合并了大量PRs,近期版本可能内存占用较大(10~20MB),我们将在功能较为收敛后进行资源占用优化.
## 📢 新闻 (News)
+2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/ROADMAP.md), 期待你的参与!
+
2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。
🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。
@@ -67,12 +71,12 @@
🤖 **AI 自举**: 纯 Go 语言原生实现 — 95% 的核心代码由 Agent 生成,并经由“人机回环 (Human-in-the-loop)”微调。
-| | OpenClaw | NanoBot | **PicoClaw** |
-| --- | --- | --- | --- |
-| **语言** | TypeScript | Python | **Go** |
-| **RAM** | >1GB | >100MB | **< 10MB** |
-| **启动时间**(0.8GHz core) | >500s | >30s | **<1s** |
-| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板****低至 $10** |
+| | OpenClaw | NanoBot | **PicoClaw** |
+| ------------------------------ | ------------- | ------------------------ | -------------------------------------- |
+| **语言** | TypeScript | Python | **Go** |
+| **RAM** | >1GB | >100MB | **< 10MB** |
+| **启动时间**(0.8GHz core) | >500s | >30s | **<1s** |
+| **成本** | Mac Mini $599 | 大多数 Linux 开发板 ~$50 | **任意 Linux 开发板****低至 $10** |
@@ -98,13 +102,31 @@
+### 📱 在手机上轻松运行
+
+picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南:
+
+1. 先去应用商店下载安装Termux
+2. 打开后执行指令
+
+```bash
+# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本
+wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64
+chmod +x picoclaw-linux-arm64
+pkg install proot
+termux-chroot ./picoclaw-linux-arm64 onboard
+```
+
+然后跟随下面的“快速开始”章节继续配置picoclaw即可使用!
+
+
### 🐜 创新的低占用部署
PicoClaw 几乎可以部署在任何 Linux 设备上!
-* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
-* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
-* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(网口) 或 W(WiFi6) 版本,用于极简家庭助手。
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html),或 $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html),用于自动化服务器运维。
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) 或 $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera),用于智能监控。
[https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4](https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4)
@@ -151,6 +173,9 @@ vim config/config.json # 设置 DISCORD_BOT_TOKEN, API keys 等
# 3. 构建并启动
docker compose --profile gateway up -d
+> [!TIP]
+**Docker 用户**: 默认情况下, Gateway监听 `127.0.0.1`,这使得这个端口未暴露到容器外。如果你需要通过端口映射访问健康检查接口, 请在环境变量中设置 `PICOCLAW_GATEWAY_HOST=0.0.0.0` 或修改 `config.json`。
+
# 4. 查看日志
docker compose logs -f picoclaw-gateway
@@ -183,7 +208,7 @@ docker compose --profile gateway up -d
> [!TIP]
> 在 `~/.picoclaw/config.json` 中设置您的 API Key。
> 获取 API Key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu (智谱)](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM)
-> 网络搜索是 **可选的** - 获取免费的 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
+> 网络搜索是 **可选的** - 获取免费的 [Tavily API](https://tavily.com) (每月 1000 次免费查询) 或 [Brave Search API](https://brave.com/search/api) (每月 2000 次免费查询)
**1. 初始化 (Initialize)**
@@ -199,34 +224,50 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
}
},
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "your-api-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "your-anthropic-key"
}
- },
+ ],
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "tavily": {
+ "enabled": false,
+ "api_key": "YOUR_TAVILY_API_KEY",
+ "max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
}
}
-
```
+> **新功能**: `model_list` 配置格式支持零代码添加 provider。详见[模型配置](#模型配置-model_list)章节。
+
**3. 获取 API Key**
* **LLM 提供商**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-* **网络搜索** (可选): [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
+* **网络搜索** (可选): [Tavily](https://tavily.com) - 专为 AI Agent 优化 (1000 请求/月) · [Brave Search](https://brave.com/search/api) - 提供免费层级 (2000 请求/月)
> **注意**: 完整的配置模板请参考 `config.example.json`。
@@ -243,176 +284,28 @@ picoclaw agent -m "2+2 等于几?"
## 💬 聊天应用集成 (Chat Apps)
-通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
+PicoClaw 支持多种聊天平台,使您的 Agent 能够连接到任何地方。
-| 渠道 | 设置难度 |
-| --- | --- |
-| **Telegram** | 简单 (仅需 token) |
-| **Discord** | 简单 (bot token + intents) |
-| **QQ** | 简单 (AppID + AppSecret) |
-| **钉钉 (DingTalk)** | 中等 (app credentials) |
+### 核心渠道
-
-Telegram (推荐)
-
-**1. 创建机器人**
-
-* 打开 Telegram,搜索 `@BotFather`
-* 发送 `/newbot`,按照提示操作
-* 复制 token
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "telegram": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
- }
- }
-}
-
-```
-
-> 从 Telegram 上的 `@userinfobot` 获取您的用户 ID。
-
-**3. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-Discord
-
-**1. 创建机器人**
-
-* 前往 [https://discord.com/developers/applications](https://discord.com/developers/applications)
-* Create an application → Bot → Add Bot
-* 复制 bot token
-
-**2. 开启 Intents**
-
-* 在 Bot 设置中,开启 **MESSAGE CONTENT INTENT**
-* (可选) 如果计划基于成员数据使用白名单,开启 **SERVER MEMBERS INTENT**
-
-**3. 获取您的 User ID**
-
-* Discord 设置 → Advanced → 开启 **Developer Mode**
-* 右键点击您的头像 → **Copy User ID**
-
-**4. 配置**
-
-```json
-{
- "channels": {
- "discord": {
- "enabled": true,
- "token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
- }
- }
-}
-
-```
-
-**5. 邀请机器人**
-
-* OAuth2 → URL Generator
-* Scopes: `bot`
-* Bot Permissions: `Send Messages`, `Read Message History`
-* 打开生成的邀请 URL,将机器人添加到您的服务器
-
-**6. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-QQ
-
-**1. 创建机器人**
-
-* 前往 [QQ 开放平台](https://q.qq.com/#)
-* 创建应用 → 获取 **AppID** 和 **AppSecret**
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "qq": {
- "enabled": true,
- "app_id": "YOUR_APP_ID",
- "app_secret": "YOUR_APP_SECRET",
- "allow_from": []
- }
- }
-}
-
-```
-
-> 将 `allow_from` 设为空以允许所有用户,或指定 QQ 号以限制访问。
-
-**3. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
-
-
-钉钉 (DingTalk)
-
-**1. 创建机器人**
-
-* 前往 [开放平台](https://open.dingtalk.com/)
-* 创建内部应用
-* 复制 Client ID 和 Client Secret
-
-**2. 配置**
-
-```json
-{
- "channels": {
- "dingtalk": {
- "enabled": true,
- "client_id": "YOUR_CLIENT_ID",
- "client_secret": "YOUR_CLIENT_SECRET",
- "allow_from": []
- }
- }
-}
-
-```
-
-> 将 `allow_from` 设为空以允许所有用户,或指定 ID 以限制访问。
-
-**3. 运行**
-
-```bash
-picoclaw gateway
-
-```
-
-
+| 渠道 | 设置难度 | 特性说明 | 文档链接 |
+| -------------------- | ----------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
+| **Telegram** | ⭐ 简单 | 推荐,支持语音转文字,长轮询无需公网 | [查看文档](docs/channels/telegram/README.zh.md) |
+| **Discord** | ⭐ 简单 | Socket Mode,支持群组/私信,Bot 生态成熟 | [查看文档](docs/channels/discord/README.zh.md) |
+| **Slack** | ⭐ 简单 | **Socket Mode** (无需公网 IP),企业级支持 | [查看文档](docs/channels/slack/README.zh.md) |
+| **QQ** | ⭐⭐ 中等 | 官方机器人 API,适合国内社群 | [查看文档](docs/channels/qq/README.zh.md) |
+| **钉钉 (DingTalk)** | ⭐⭐ 中等 | Stream 模式无需公网,企业办公首选 | [查看文档](docs/channels/dingtalk/README.zh.md) |
+| **企业微信 (WeCom)** | ⭐⭐⭐ 较难 | 支持群机器人(Webhook)和自建应用(API) | [Bot 文档](docs/channels/wecom/wecom_bot/README.zh.md) / [App 文档](docs/channels/wecom/wecom_app/README.zh.md) |
+| **飞书 (Feishu)** | ⭐⭐⭐ 较难 | 企业级协作,功能丰富 | [查看文档](docs/channels/feishu/README.zh.md) |
+| **Line** | ⭐⭐⭐ 较难 | 需要 HTTPS Webhook | [查看文档](docs/channels/line/README.zh.md) |
+| **OneBot** | ⭐⭐ 中等 | 兼容 NapCat/Go-CQHTTP,社区生态丰富 | [查看文档](docs/channels/onebot/README.zh.md) |
+| **MaixCam** | ⭐ 简单 | 专为 AI 摄像头设计的硬件集成通道 | [查看文档](docs/channels/maixcam/README.zh.md) |
## 加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
-**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai)
+\*\*阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
## ⚙️ 配置详解
@@ -448,7 +341,6 @@ PicoClaw 可以自动执行周期性任务。在工作区创建 `HEARTBEAT.md`
- Check my email for important messages
- Review my calendar for upcoming events
- Check the weather forecast
-
```
Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具执行任务。
@@ -461,22 +353,23 @@ Agent 将每隔 30 分钟(可配置)读取此文件,并使用可用工具
# Periodic Tasks
## Quick Tasks (respond directly)
+
- Report current time
## Long Tasks (use spawn for async)
+
- Search the web for AI news and summarize
- Check email and report important messages
-
```
**关键行为:**
-| 特性 | 描述 |
-| --- | --- |
-| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
-| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
+| 特性 | 描述 |
+| ---------------- | ---------------------------------------- |
+| **spawn** | 创建异步子 Agent,不阻塞主心跳进程 |
+| **独立上下文** | 子 Agent 拥有独立上下文,无会话历史 |
| **message tool** | 子 Agent 通过 message 工具直接与用户通信 |
-| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
+| **非阻塞** | spawn 后,心跳继续处理下一个任务 |
#### 子 Agent 通信原理
@@ -506,40 +399,234 @@ Agent 读取 HEARTBEAT.md
"interval": 30
}
}
-
```
-| 选项 | 默认值 | 描述 |
-| --- | --- | --- |
-| `enabled` | `true` | 启用/禁用心跳 |
-| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
+| 选项 | 默认值 | 描述 |
+| ---------- | ------ | ---------------------------- |
+| `enabled` | `true` | 启用/禁用心跳 |
+| `interval` | `30` | 检查间隔,单位分钟 (最小: 5) |
**环境变量:**
-* `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
-* `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
+- `PICOCLAW_HEARTBEAT_ENABLED=false` 禁用
+- `PICOCLAW_HEARTBEAT_INTERVAL=60` 更改间隔
### 提供商 (Providers)
> [!NOTE]
> Groq 通过 Whisper 提供免费的语音转录。如果配置了 Groq,Telegram 语音消息将被自动转录为文字。
-| 提供商 | 用途 | 获取 API Key |
-| --- | --- | --- |
-| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
-| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
-| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
-| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
-| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
-| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
-| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
+| 提供商 | 用途 | 获取 API Key |
+| -------------------- | ---------------------------- | -------------------------------------------------------------------- |
+| `gemini` | LLM (Gemini 直连) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM (智谱直连) | [bigmodel.cn](bigmodel.cn) |
+| `openrouter(待测试)` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) |
+| `anthropic(待测试)` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) |
+| `openai(待测试)` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) |
+| `deepseek(待测试)` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
+| `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras 直连) | [cerebras.ai](https://cerebras.ai) |
+
+### 模型配置 (model_list)
+
+> **新功能!** PicoClaw 现在采用**以模型为中心**的配置方式。只需使用 `厂商/模型` 格式(如 `zhipu/glm-4.7`)即可添加新的 provider——**无需修改任何代码!**
+
+该设计同时支持**多 Agent 场景**,提供灵活的 Provider 选择:
+
+- **不同 Agent 使用不同 Provider**:每个 Agent 可以使用自己的 LLM provider
+- **模型回退(Fallback)**:配置主模型和备用模型,提高可靠性
+- **负载均衡**:在多个 API 端点之间分配请求
+- **集中化配置**:在一个地方管理所有 provider
+
+#### 📋 所有支持的厂商
+
+| 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key |
+| ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- |
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [获取密钥](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [获取密钥](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [获取密钥](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [获取密钥](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [获取密钥](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | 本地(无需密钥) |
+| **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) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | 自定义 | 仅 OAuth |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### 基础配置示例
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-zhipu-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+#### 各厂商配置示例
+
+**OpenAI**
+
+```json
+{
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-..."
+}
+```
+
+**智谱 AI (GLM)**
+
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**DeepSeek**
+
+```json
+{
+ "model_name": "deepseek-chat",
+ "model": "deepseek/deepseek-chat",
+ "api_key": "sk-..."
+}
+```
+
+**Anthropic (使用 OAuth)**
+
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+
+> 运行 `picoclaw auth login --provider anthropic` 来设置 OAuth 凭证。
+
+**Ollama (本地)**
+
+```json
+{
+ "model_name": "llama3",
+ "model": "ollama/llama3"
+}
+```
+
+**自定义代理/API**
+
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-..."
+}
+```
+
+#### 负载均衡
+
+为同一个模型名称配置多个端点——PicoClaw 会自动在它们之间轮询:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api1.example.com/v1",
+ "api_key": "sk-key1"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_base": "https://api2.example.com/v1",
+ "api_key": "sk-key2"
+ }
+ ]
+}
+```
+
+#### 从旧的 `providers` 配置迁移
+
+旧的 `providers` 配置格式**已弃用**,但为向后兼容仍支持。
+
+**旧配置(已弃用):**
+
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**新配置(推荐):**
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+详细的迁移指南请参考 [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md)。
智谱 (Zhipu) 配置示例
**1. 获取 API key 和 base URL**
-* 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+- 获取 [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
**2. 配置**
@@ -558,10 +645,9 @@ Agent 读取 HEARTBEAT.md
"zhipu": {
"api_key": "Your API Key",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
- },
- },
+ }
+ }
}
-
```
**3. 运行**
@@ -622,9 +708,18 @@ picoclaw agent -m "你好"
},
"tools": {
"web": {
- "search": {
- "api_key": "BSA..."
+ "brave": {
+ "enabled": false,
+ "api_key": "YOUR_BRAVE_API_KEY",
+ "max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
}
},
"heartbeat": {
@@ -632,30 +727,29 @@ picoclaw agent -m "你好"
"interval": 30
}
}
-
```
## CLI 命令行参考
-| 命令 | 描述 |
-| --- | --- |
-| `picoclaw onboard` | 初始化配置和工作区 |
-| `picoclaw agent -m "..."` | 与 Agent 对话 |
-| `picoclaw agent` | 交互式聊天模式 |
-| `picoclaw gateway` | 启动网关 (Gateway) |
-| `picoclaw status` | 显示状态 |
-| `picoclaw cron list` | 列出所有定时任务 |
-| `picoclaw cron add ...` | 添加定时任务 |
+| 命令 | 描述 |
+| ------------------------- | ------------------ |
+| `picoclaw onboard` | 初始化配置和工作区 |
+| `picoclaw agent -m "..."` | 与 Agent 对话 |
+| `picoclaw agent` | 交互式聊天模式 |
+| `picoclaw gateway` | 启动网关 (Gateway) |
+| `picoclaw status` | 显示状态 |
+| `picoclaw cron list` | 列出所有定时任务 |
+| `picoclaw cron add ...` | 添加定时任务 |
### 定时任务 / 提醒 (Scheduled Tasks)
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
-* **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
-* **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
-* **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
+- **一次性提醒**: "Remind me in 10 minutes" (10分钟后提醒我) → 10分钟后触发一次
+- **重复任务**: "Remind me every 2 hours" (每2小时提醒我) → 每2小时触发
+- **Cron 表达式**: "Remind me at 9am daily" (每天上午9点提醒我) → 使用 cron 表达式
任务存储在 `~/.picoclaw/workspace/cron/` 中并自动处理。
@@ -669,7 +763,7 @@ PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:
用户群组:
-Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
+Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
@@ -681,24 +775,27 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
启用网络搜索:
-1. 在 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (每月 2000 次免费查询)
+1. 在 [https://tavily.com](https://tavily.com) (1000 次免费) 或 [https://brave.com/search/api](https://brave.com/search/api) 获取免费 API Key (2000 次免费)
2. 添加到 `~/.picoclaw/config.json`:
+
```json
{
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
}
}
}
-
```
-
-
### 遇到内容过滤错误 (Content Filtering Errors)
某些提供商(如智谱)有严格的内容过滤。尝试改写您的问题或使用其他模型。
@@ -716,4 +813,5 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
| **OpenRouter** | 200K tokens/月 | 多模型聚合 (Claude, GPT-4 等) |
| **智谱 (Zhipu)** | 200K tokens/月 | 最适合中国用户 |
| **Brave Search** | 2000 次查询/月 | 网络搜索功能 |
-| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
\ No newline at end of file
+| **Tavily** | 1000 次查询/月 | AI Agent 搜索优化 |
+| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
diff --git a/ROADMAP.md b/ROADMAP.md
new file mode 100644
index 000000000..8c5c0e252
--- /dev/null
+++ b/ROADMAP.md
@@ -0,0 +1,116 @@
+
+# 🦐 PicoClaw Roadmap
+
+> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity
+
+---
+
+## 🚀 1. Core Optimization: Extreme Lightweight
+
+*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.*
+
+* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346)
+ * **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB.
+ * **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size.
+ * **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures.
+
+
+## 🛡️ 2. Security Hardening: Defense in Depth
+
+*Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.*
+
+* **Input Defense & Permission Control**
+ * **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation.
+ * **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries.
+ * **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services).
+
+
+* **Sandboxing & Isolation**
+ * **Filesystem Sandbox**: Restrict file R/W operations to specific directories only.
+ * **Context Isolation**: Prevent data leakage between different user sessions or channels.
+ * **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs.
+
+
+* **Authentication & Secrets**
+ * **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage.
+ * **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows.
+
+
+
+## 🔌 3. Connectivity: Protocol-First Architecture
+
+*Connect every model, reach every platform.*
+
+* **Provider**
+ * [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)*
+ * **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference).
+ * **Online Models**: Continued support for frontier closed-source models.
+
+
+* **Channel**
+ * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ...
+ * **Standards**: Support for the **OneBot** protocol.
+ * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments.
+
+
+* **Skill Marketplace**
+ * [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries.
+
+
+
+## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI
+
+*Beyond conversation—focusing on action and collaboration.*
+
+* **Operations**
+ * [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**.
+ * [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook.
+ * [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop).
+
+
+* **Multi-Agent Collaboration**
+ * [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement
+ * [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart).
+ * [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network.
+ * [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms.
+
+
+
+## 📚 5. Developer Experience (DevEx) & Documentation
+
+*Lowering the barrier to entry so anyone can deploy in minutes.*
+
+* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350)
+ * Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step.
+
+
+* **Comprehensive Documentation**
+ * **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android.
+ * **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels.
+ * **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations).
+
+
+
+## 🤖 6. Engineering: AI-Powered Open Source
+
+*Born from Vibe Coding, we continue to use AI to accelerate development.*
+
+* **AI-Enhanced CI/CD**
+ * Integrate AI for automated Code Review, Linting, and PR Labeling.
+ * **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean.
+ * **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes.
+
+
+
+## 🎨 7. Brand & Community
+
+* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design!
+ * *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes."
+
+
+
+---
+
+### 🤝 Call for Contributions
+
+We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together!
\ No newline at end of file
diff --git a/assets/termux.jpg b/assets/termux.jpg
new file mode 100644
index 000000000..30c724a20
Binary files /dev/null and b/assets/termux.jpg differ
diff --git a/assets/wechat.png b/assets/wechat.png
index d62c8d09d..e30c34e4e 100644
Binary files a/assets/wechat.png and b/assets/wechat.png differ
diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go
new file mode 100644
index 000000000..98ea51103
--- /dev/null
+++ b/cmd/picoclaw/cmd_agent.go
@@ -0,0 +1,181 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/chzyer/readline"
+
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+func agentCmd() {
+ message := ""
+ sessionKey := "cli:default"
+ modelOverride := ""
+
+ args := os.Args[2:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--debug", "-d":
+ logger.SetLevel(logger.DEBUG)
+ fmt.Println("🔍 Debug mode enabled")
+ case "-m", "--message":
+ if i+1 < len(args) {
+ message = args[i+1]
+ i++
+ }
+ case "-s", "--session":
+ if i+1 < len(args) {
+ sessionKey = args[i+1]
+ i++
+ }
+ case "--model", "-model":
+ if i+1 < len(args) {
+ modelOverride = args[i+1]
+ i++
+ }
+ }
+ }
+
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ os.Exit(1)
+ }
+
+ if modelOverride != "" {
+ cfg.Agents.Defaults.ModelName = modelOverride
+ }
+
+ provider, modelID, err := providers.CreateProvider(cfg)
+ if err != nil {
+ fmt.Printf("Error creating provider: %v\n", err)
+ os.Exit(1)
+ }
+ // Use the resolved model ID from provider creation
+ if modelID != "" {
+ cfg.Agents.Defaults.ModelName = modelID
+ }
+
+ msgBus := bus.NewMessageBus()
+ agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
+
+ // Print agent startup info (only for interactive mode)
+ startupInfo := agentLoop.GetStartupInfo()
+ logger.InfoCF("agent", "Agent initialized",
+ map[string]any{
+ "tools_count": startupInfo["tools"].(map[string]any)["count"],
+ "skills_total": startupInfo["skills"].(map[string]any)["total"],
+ "skills_available": startupInfo["skills"].(map[string]any)["available"],
+ })
+
+ if message != "" {
+ ctx := context.Background()
+ response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ os.Exit(1)
+ }
+ fmt.Printf("\n%s %s\n", logo, response)
+ } else {
+ fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
+ interactiveMode(agentLoop, sessionKey)
+ }
+}
+
+func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
+ prompt := fmt.Sprintf("%s You: ", logo)
+
+ rl, err := readline.NewEx(&readline.Config{
+ Prompt: prompt,
+ HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
+ HistoryLimit: 100,
+ InterruptPrompt: "^C",
+ EOFPrompt: "exit",
+ })
+ if err != nil {
+ fmt.Printf("Error initializing readline: %v\n", err)
+ fmt.Println("Falling back to simple input mode...")
+ simpleInteractiveMode(agentLoop, sessionKey)
+ return
+ }
+ defer rl.Close()
+
+ for {
+ line, err := rl.Readline()
+ if err != nil {
+ if err == readline.ErrInterrupt || err == io.EOF {
+ fmt.Println("\nGoodbye!")
+ return
+ }
+ fmt.Printf("Error reading input: %v\n", err)
+ continue
+ }
+
+ input := strings.TrimSpace(line)
+ if input == "" {
+ continue
+ }
+
+ if input == "exit" || input == "quit" {
+ fmt.Println("Goodbye!")
+ return
+ }
+
+ ctx := context.Background()
+ response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ continue
+ }
+
+ fmt.Printf("\n%s %s\n\n", logo, response)
+ }
+}
+
+func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
+ reader := bufio.NewReader(os.Stdin)
+ for {
+ fmt.Printf("%s You: ", logo)
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err == io.EOF {
+ fmt.Println("\nGoodbye!")
+ return
+ }
+ fmt.Printf("Error reading input: %v\n", err)
+ continue
+ }
+
+ input := strings.TrimSpace(line)
+ if input == "" {
+ continue
+ }
+
+ if input == "exit" || input == "quit" {
+ fmt.Println("Goodbye!")
+ return
+ }
+
+ ctx := context.Background()
+ response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ continue
+ }
+
+ fmt.Printf("\n%s %s\n\n", logo, response)
+ }
+}
diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go
new file mode 100644
index 000000000..55eb3cec3
--- /dev/null
+++ b/cmd/picoclaw/cmd_auth.go
@@ -0,0 +1,512 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+const supportedProvidersMsg = "Supported providers: openai, anthropic, google-antigravity"
+
+func authCmd() {
+ if len(os.Args) < 3 {
+ authHelp()
+ return
+ }
+
+ switch os.Args[2] {
+ case "login":
+ authLoginCmd()
+ case "logout":
+ authLogoutCmd()
+ case "status":
+ authStatusCmd()
+ case "models":
+ authModelsCmd()
+ default:
+ fmt.Printf("Unknown auth command: %s\n", os.Args[2])
+ authHelp()
+ }
+}
+
+func authHelp() {
+ fmt.Println("\nAuth commands:")
+ fmt.Println(" login Login via OAuth or paste token")
+ fmt.Println(" logout Remove stored credentials")
+ fmt.Println(" status Show current auth status")
+ fmt.Println(" models List available Antigravity models")
+ fmt.Println()
+ fmt.Println("Login options:")
+ fmt.Println(" --provider Provider to login with (openai, anthropic, google-antigravity)")
+ fmt.Println(" --device-code Use device code flow (for headless environments)")
+ fmt.Println()
+ fmt.Println("Examples:")
+ fmt.Println(" picoclaw auth login --provider openai")
+ fmt.Println(" picoclaw auth login --provider openai --device-code")
+ fmt.Println(" picoclaw auth login --provider anthropic")
+ fmt.Println(" picoclaw auth login --provider google-antigravity")
+ fmt.Println(" picoclaw auth models")
+ fmt.Println(" picoclaw auth logout --provider openai")
+ fmt.Println(" picoclaw auth status")
+}
+
+func authLoginCmd() {
+ provider := ""
+ useDeviceCode := false
+
+ args := os.Args[3:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--provider", "-p":
+ if i+1 < len(args) {
+ provider = args[i+1]
+ i++
+ }
+ case "--device-code":
+ useDeviceCode = true
+ }
+ }
+
+ if provider == "" {
+ fmt.Println("Error: --provider is required")
+ fmt.Println(supportedProvidersMsg)
+ return
+ }
+
+ switch provider {
+ case "openai":
+ authLoginOpenAI(useDeviceCode)
+ case "anthropic":
+ authLoginPasteToken(provider)
+ case "google-antigravity", "antigravity":
+ authLoginGoogleAntigravity()
+ default:
+ fmt.Printf("Unsupported provider: %s\n", provider)
+ fmt.Println(supportedProvidersMsg)
+ }
+}
+
+func authLoginOpenAI(useDeviceCode bool) {
+ cfg := auth.OpenAIOAuthConfig()
+
+ var cred *auth.AuthCredential
+ var err error
+
+ if useDeviceCode {
+ cred, err = auth.LoginDeviceCode(cfg)
+ } else {
+ cred, err = auth.LoginBrowser(cfg)
+ }
+
+ if err != nil {
+ fmt.Printf("Login failed: %v\n", err)
+ os.Exit(1)
+ }
+
+ if err = auth.SetCredential("openai", cred); err != nil {
+ fmt.Printf("Failed to save credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ // Update Providers (legacy format)
+ appCfg.Providers.OpenAI.AuthMethod = "oauth"
+
+ // Update or add openai in ModelList
+ foundOpenAI := false
+ for i := range appCfg.ModelList {
+ if isOpenAIModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = "oauth"
+ foundOpenAI = true
+ break
+ }
+ }
+
+ // 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",
+ AuthMethod: "oauth",
+ })
+ }
+
+ // Update default model to use OpenAI
+ appCfg.Agents.Defaults.ModelName = "gpt-5.2"
+
+ if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
+ fmt.Printf("Warning: could not update config: %v\n", err)
+ }
+ }
+
+ fmt.Println("Login successful!")
+ if cred.AccountID != "" {
+ fmt.Printf("Account: %s\n", cred.AccountID)
+ }
+ fmt.Println("Default model set to: gpt-5.2")
+}
+
+func authLoginGoogleAntigravity() {
+ cfg := auth.GoogleAntigravityOAuthConfig()
+
+ cred, err := auth.LoginBrowser(cfg)
+ if err != nil {
+ fmt.Printf("Login failed: %v\n", err)
+ os.Exit(1)
+ }
+
+ cred.Provider = "google-antigravity"
+
+ // Fetch user email from Google userinfo
+ email, err := fetchGoogleUserEmail(cred.AccessToken)
+ if err != nil {
+ fmt.Printf("Warning: could not fetch email: %v\n", err)
+ } else {
+ cred.Email = email
+ fmt.Printf("Email: %s\n", email)
+ }
+
+ // Fetch Cloud Code Assist project ID
+ projectID, err := providers.FetchAntigravityProjectID(cred.AccessToken)
+ if err != nil {
+ fmt.Printf("Warning: could not fetch project ID: %v\n", err)
+ fmt.Println("You may need Google Cloud Code Assist enabled on your account.")
+ } else {
+ cred.ProjectID = projectID
+ fmt.Printf("Project: %s\n", projectID)
+ }
+
+ if err = auth.SetCredential("google-antigravity", cred); err != nil {
+ fmt.Printf("Failed to save credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ // Update Providers (legacy format, for backward compatibility)
+ appCfg.Providers.Antigravity.AuthMethod = "oauth"
+
+ // Update or add antigravity in ModelList
+ foundAntigravity := false
+ for i := range appCfg.ModelList {
+ if isAntigravityModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = "oauth"
+ foundAntigravity = true
+ break
+ }
+ }
+
+ // If no antigravity in ModelList, add it
+ if !foundAntigravity {
+ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ ModelName: "gemini-flash",
+ Model: "antigravity/gemini-3-flash",
+ AuthMethod: "oauth",
+ })
+ }
+
+ // Update default model
+ appCfg.Agents.Defaults.ModelName = "gemini-flash"
+
+ if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
+ fmt.Printf("Warning: could not update config: %v\n", err)
+ }
+ }
+
+ fmt.Println("\n✓ Google Antigravity login successful!")
+ fmt.Println("Default model set to: gemini-flash")
+ fmt.Println("Try it: picoclaw agent -m \"Hello world\"")
+}
+
+func fetchGoogleUserEmail(accessToken string) (string, error) {
+ req, err := http.NewRequest("GET", "https://www.googleapis.com/oauth2/v2/userinfo", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Authorization", "Bearer "+accessToken)
+
+ client := &http.Client{Timeout: 10 * time.Second}
+ resp, err := client.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("userinfo request failed: %s", string(body))
+ }
+
+ var userInfo struct {
+ Email string `json:"email"`
+ }
+ if err := json.Unmarshal(body, &userInfo); err != nil {
+ return "", err
+ }
+ return userInfo.Email, nil
+}
+
+func authLoginPasteToken(provider string) {
+ cred, err := auth.LoginPasteToken(provider, os.Stdin)
+ if err != nil {
+ fmt.Printf("Login failed: %v\n", err)
+ os.Exit(1)
+ }
+
+ if err = auth.SetCredential(provider, cred); err != nil {
+ fmt.Printf("Failed to save credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ switch provider {
+ case "anthropic":
+ appCfg.Providers.Anthropic.AuthMethod = "token"
+ // Update ModelList
+ found := false
+ for i := range appCfg.ModelList {
+ if isAnthropicModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = "token"
+ found = true
+ break
+ }
+ }
+ if !found {
+ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ ModelName: "claude-sonnet-4.6",
+ Model: "anthropic/claude-sonnet-4.6",
+ AuthMethod: "token",
+ })
+ }
+ // Update default model
+ appCfg.Agents.Defaults.ModelName = "claude-sonnet-4.6"
+ case "openai":
+ appCfg.Providers.OpenAI.AuthMethod = "token"
+ // Update ModelList
+ found := false
+ for i := range appCfg.ModelList {
+ if isOpenAIModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = "token"
+ found = true
+ break
+ }
+ }
+ if !found {
+ appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
+ ModelName: "gpt-5.2",
+ Model: "openai/gpt-5.2",
+ AuthMethod: "token",
+ })
+ }
+ // Update default model
+ appCfg.Agents.Defaults.ModelName = "gpt-5.2"
+ }
+ if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
+ fmt.Printf("Warning: could not update config: %v\n", err)
+ }
+ }
+
+ fmt.Printf("Token saved for %s!\n", provider)
+ fmt.Printf("Default model set to: %s\n", appCfg.Agents.Defaults.GetModelName())
+}
+
+func authLogoutCmd() {
+ provider := ""
+
+ args := os.Args[3:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--provider", "-p":
+ if i+1 < len(args) {
+ provider = args[i+1]
+ i++
+ }
+ }
+ }
+
+ if provider != "" {
+ if err := auth.DeleteCredential(provider); err != nil {
+ fmt.Printf("Failed to remove credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ // Clear AuthMethod in ModelList
+ for i := range appCfg.ModelList {
+ switch provider {
+ case "openai":
+ if isOpenAIModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ case "anthropic":
+ if isAnthropicModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ case "google-antigravity", "antigravity":
+ if isAntigravityModel(appCfg.ModelList[i].Model) {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ }
+ }
+ // Clear AuthMethod in Providers (legacy)
+ switch provider {
+ case "openai":
+ appCfg.Providers.OpenAI.AuthMethod = ""
+ case "anthropic":
+ appCfg.Providers.Anthropic.AuthMethod = ""
+ case "google-antigravity", "antigravity":
+ appCfg.Providers.Antigravity.AuthMethod = ""
+ }
+ config.SaveConfig(getConfigPath(), appCfg)
+ }
+
+ fmt.Printf("Logged out from %s\n", provider)
+ } else {
+ if err := auth.DeleteAllCredentials(); err != nil {
+ fmt.Printf("Failed to remove credentials: %v\n", err)
+ os.Exit(1)
+ }
+
+ appCfg, err := loadConfig()
+ if err == nil {
+ // Clear all AuthMethods in ModelList
+ for i := range appCfg.ModelList {
+ appCfg.ModelList[i].AuthMethod = ""
+ }
+ // Clear all AuthMethods in Providers (legacy)
+ appCfg.Providers.OpenAI.AuthMethod = ""
+ appCfg.Providers.Anthropic.AuthMethod = ""
+ appCfg.Providers.Antigravity.AuthMethod = ""
+ config.SaveConfig(getConfigPath(), appCfg)
+ }
+
+ fmt.Println("Logged out from all providers")
+ }
+}
+
+func authStatusCmd() {
+ store, err := auth.LoadStore()
+ if err != nil {
+ fmt.Printf("Error loading auth store: %v\n", err)
+ return
+ }
+
+ if len(store.Credentials) == 0 {
+ fmt.Println("No authenticated providers.")
+ fmt.Println("Run: picoclaw auth login --provider ")
+ return
+ }
+
+ fmt.Println("\nAuthenticated Providers:")
+ fmt.Println("------------------------")
+ for provider, cred := range store.Credentials {
+ status := "active"
+ if cred.IsExpired() {
+ status = "expired"
+ } else if cred.NeedsRefresh() {
+ status = "needs refresh"
+ }
+
+ fmt.Printf(" %s:\n", provider)
+ fmt.Printf(" Method: %s\n", cred.AuthMethod)
+ fmt.Printf(" Status: %s\n", status)
+ if cred.AccountID != "" {
+ fmt.Printf(" Account: %s\n", cred.AccountID)
+ }
+ if cred.Email != "" {
+ fmt.Printf(" Email: %s\n", cred.Email)
+ }
+ if cred.ProjectID != "" {
+ fmt.Printf(" Project: %s\n", cred.ProjectID)
+ }
+ if !cred.ExpiresAt.IsZero() {
+ fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
+ }
+ }
+}
+
+func authModelsCmd() {
+ cred, err := auth.GetCredential("google-antigravity")
+ if err != nil || cred == nil {
+ fmt.Println("Not logged in to Google Antigravity.")
+ fmt.Println("Run: picoclaw auth login --provider google-antigravity")
+ return
+ }
+
+ // Refresh token if needed
+ if cred.NeedsRefresh() && cred.RefreshToken != "" {
+ oauthCfg := auth.GoogleAntigravityOAuthConfig()
+ refreshed, refreshErr := auth.RefreshAccessToken(cred, oauthCfg)
+ if refreshErr == nil {
+ cred = refreshed
+ _ = auth.SetCredential("google-antigravity", cred)
+ }
+ }
+
+ projectID := cred.ProjectID
+ if projectID == "" {
+ fmt.Println("No project ID stored. Try logging in again.")
+ return
+ }
+
+ fmt.Printf("Fetching models for project: %s\n\n", projectID)
+
+ models, err := providers.FetchAntigravityModels(cred.AccessToken, projectID)
+ if err != nil {
+ fmt.Printf("Error fetching models: %v\n", err)
+ return
+ }
+
+ if len(models) == 0 {
+ fmt.Println("No models available.")
+ return
+ }
+
+ fmt.Println("Available Antigravity Models:")
+ fmt.Println("-----------------------------")
+ for _, m := range models {
+ status := "✓"
+ if m.IsExhausted {
+ status = "✗ (quota exhausted)"
+ }
+ name := m.ID
+ if m.DisplayName != "" {
+ name = fmt.Sprintf("%s (%s)", m.ID, m.DisplayName)
+ }
+ fmt.Printf(" %s %s\n", status, name)
+ }
+}
+
+// isAntigravityModel checks if a model string belongs to antigravity provider
+func isAntigravityModel(model string) bool {
+ return model == "antigravity" ||
+ model == "google-antigravity" ||
+ strings.HasPrefix(model, "antigravity/") ||
+ strings.HasPrefix(model, "google-antigravity/")
+}
+
+// isOpenAIModel checks if a model string belongs to openai provider
+func isOpenAIModel(model string) bool {
+ return model == "openai" ||
+ strings.HasPrefix(model, "openai/")
+}
+
+// isAnthropicModel checks if a model string belongs to anthropic provider
+func isAnthropicModel(model string) bool {
+ return model == "anthropic" ||
+ strings.HasPrefix(model, "anthropic/")
+}
diff --git a/cmd/picoclaw/cmd_cron.go b/cmd/picoclaw/cmd_cron.go
new file mode 100644
index 000000000..8c42bde06
--- /dev/null
+++ b/cmd/picoclaw/cmd_cron.go
@@ -0,0 +1,227 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/cron"
+)
+
+func cronCmd() {
+ if len(os.Args) < 3 {
+ cronHelp()
+ return
+ }
+
+ subcommand := os.Args[2]
+
+ // Load config to get workspace path
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ return
+ }
+
+ cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
+
+ switch subcommand {
+ case "list":
+ cronListCmd(cronStorePath)
+ case "add":
+ cronAddCmd(cronStorePath)
+ case "remove":
+ if len(os.Args) < 4 {
+ fmt.Println("Usage: picoclaw cron remove ")
+ return
+ }
+ cronRemoveCmd(cronStorePath, os.Args[3])
+ case "enable":
+ cronEnableCmd(cronStorePath, false)
+ case "disable":
+ cronEnableCmd(cronStorePath, true)
+ default:
+ fmt.Printf("Unknown cron command: %s\n", subcommand)
+ cronHelp()
+ }
+}
+
+func cronHelp() {
+ fmt.Println("\nCron commands:")
+ fmt.Println(" list List all scheduled jobs")
+ fmt.Println(" add Add a new scheduled job")
+ fmt.Println(" remove Remove a job by ID")
+ fmt.Println(" enable Enable a job")
+ fmt.Println(" disable Disable a job")
+ fmt.Println()
+ fmt.Println("Add options:")
+ fmt.Println(" -n, --name Job name")
+ fmt.Println(" -m, --message Message for agent")
+ fmt.Println(" -e, --every Run every N seconds")
+ fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
+ fmt.Println(" -d, --deliver Deliver response to channel")
+ fmt.Println(" --to Recipient for delivery")
+ fmt.Println(" --channel Channel for delivery")
+}
+
+func cronListCmd(storePath string) {
+ cs := cron.NewCronService(storePath, nil)
+ jobs := cs.ListJobs(true) // Show all jobs, including disabled
+
+ if len(jobs) == 0 {
+ fmt.Println("No scheduled jobs.")
+ return
+ }
+
+ fmt.Println("\nScheduled Jobs:")
+ fmt.Println("----------------")
+ for _, job := range jobs {
+ var schedule string
+ if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
+ schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
+ } else if job.Schedule.Kind == "cron" {
+ schedule = job.Schedule.Expr
+ } else {
+ schedule = "one-time"
+ }
+
+ nextRun := "scheduled"
+ if job.State.NextRunAtMS != nil {
+ nextTime := time.UnixMilli(*job.State.NextRunAtMS)
+ nextRun = nextTime.Format("2006-01-02 15:04")
+ }
+
+ status := "enabled"
+ if !job.Enabled {
+ status = "disabled"
+ }
+
+ fmt.Printf(" %s (%s)\n", job.Name, job.ID)
+ fmt.Printf(" Schedule: %s\n", schedule)
+ fmt.Printf(" Status: %s\n", status)
+ fmt.Printf(" Next run: %s\n", nextRun)
+ }
+}
+
+func cronAddCmd(storePath string) {
+ name := ""
+ message := ""
+ var everySec *int64
+ cronExpr := ""
+ deliver := false
+ channel := ""
+ to := ""
+
+ args := os.Args[3:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "-n", "--name":
+ if i+1 < len(args) {
+ name = args[i+1]
+ i++
+ }
+ case "-m", "--message":
+ if i+1 < len(args) {
+ message = args[i+1]
+ i++
+ }
+ case "-e", "--every":
+ if i+1 < len(args) {
+ var sec int64
+ fmt.Sscanf(args[i+1], "%d", &sec)
+ everySec = &sec
+ i++
+ }
+ case "-c", "--cron":
+ if i+1 < len(args) {
+ cronExpr = args[i+1]
+ i++
+ }
+ case "-d", "--deliver":
+ deliver = true
+ case "--to":
+ if i+1 < len(args) {
+ to = args[i+1]
+ i++
+ }
+ case "--channel":
+ if i+1 < len(args) {
+ channel = args[i+1]
+ i++
+ }
+ }
+ }
+
+ if name == "" {
+ fmt.Println("Error: --name is required")
+ return
+ }
+
+ if message == "" {
+ fmt.Println("Error: --message is required")
+ return
+ }
+
+ if everySec == nil && cronExpr == "" {
+ fmt.Println("Error: Either --every or --cron must be specified")
+ return
+ }
+
+ var schedule cron.CronSchedule
+ if everySec != nil {
+ everyMS := *everySec * 1000
+ schedule = cron.CronSchedule{
+ Kind: "every",
+ EveryMS: &everyMS,
+ }
+ } else {
+ schedule = cron.CronSchedule{
+ Kind: "cron",
+ Expr: cronExpr,
+ }
+ }
+
+ cs := cron.NewCronService(storePath, nil)
+ job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
+ if err != nil {
+ fmt.Printf("Error adding job: %v\n", err)
+ return
+ }
+
+ fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
+}
+
+func cronRemoveCmd(storePath, jobID string) {
+ cs := cron.NewCronService(storePath, nil)
+ if cs.RemoveJob(jobID) {
+ fmt.Printf("✓ Removed job %s\n", jobID)
+ } else {
+ fmt.Printf("✗ Job %s not found\n", jobID)
+ }
+}
+
+func cronEnableCmd(storePath string, disable bool) {
+ if len(os.Args) < 4 {
+ fmt.Println("Usage: picoclaw cron enable/disable ")
+ return
+ }
+
+ jobID := os.Args[3]
+ cs := cron.NewCronService(storePath, nil)
+ enabled := !disable
+
+ job := cs.EnableJob(jobID, enabled)
+ if job != nil {
+ status := "enabled"
+ if disable {
+ status = "disabled"
+ }
+ fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
+ } else {
+ fmt.Printf("✗ Job %s not found\n", jobID)
+ }
+}
diff --git a/cmd/picoclaw/cmd_gateway.go b/cmd/picoclaw/cmd_gateway.go
new file mode 100644
index 000000000..cf7f3563a
--- /dev/null
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -0,0 +1,248 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/agent"
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/cron"
+ "github.com/sipeed/picoclaw/pkg/devices"
+ "github.com/sipeed/picoclaw/pkg/health"
+ "github.com/sipeed/picoclaw/pkg/heartbeat"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/state"
+ "github.com/sipeed/picoclaw/pkg/tools"
+ "github.com/sipeed/picoclaw/pkg/voice"
+)
+
+func gatewayCmd() {
+ // Check for --debug flag
+ args := os.Args[2:]
+ for _, arg := range args {
+ if arg == "--debug" || arg == "-d" {
+ logger.SetLevel(logger.DEBUG)
+ fmt.Println("🔍 Debug mode enabled")
+ break
+ }
+ }
+
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ os.Exit(1)
+ }
+
+ provider, modelID, err := providers.CreateProvider(cfg)
+ if err != nil {
+ fmt.Printf("Error creating provider: %v\n", err)
+ os.Exit(1)
+ }
+ // Use the resolved model ID from provider creation
+ if modelID != "" {
+ cfg.Agents.Defaults.ModelName = modelID
+ }
+
+ msgBus := bus.NewMessageBus()
+ agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
+
+ // Print agent startup info
+ fmt.Println("\n📦 Agent Status:")
+ startupInfo := agentLoop.GetStartupInfo()
+ toolsInfo := startupInfo["tools"].(map[string]any)
+ skillsInfo := startupInfo["skills"].(map[string]any)
+ fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
+ fmt.Printf(" • Skills: %d/%d available\n",
+ skillsInfo["available"],
+ skillsInfo["total"])
+
+ // Log to file as well
+ logger.InfoCF("agent", "Agent initialized",
+ map[string]any{
+ "tools_count": toolsInfo["count"],
+ "skills_total": skillsInfo["total"],
+ "skills_available": skillsInfo["available"],
+ })
+
+ // Setup cron tool and service
+ execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute
+ cronService := setupCronTool(
+ agentLoop,
+ msgBus,
+ cfg.WorkspacePath(),
+ cfg.Agents.Defaults.RestrictToWorkspace,
+ execTimeout,
+ cfg,
+ )
+
+ heartbeatService := heartbeat.NewHeartbeatService(
+ cfg.WorkspacePath(),
+ cfg.Heartbeat.Interval,
+ cfg.Heartbeat.Enabled,
+ )
+ heartbeatService.SetBus(msgBus)
+ heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
+ // Use cli:direct as fallback if no valid channel
+ if channel == "" || chatID == "" {
+ channel, chatID = "cli", "direct"
+ }
+ // Use ProcessHeartbeat - no session history, each heartbeat is independent
+ var response string
+ response, err = agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
+ if err != nil {
+ return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
+ }
+ if response == "HEARTBEAT_OK" {
+ return tools.SilentResult("Heartbeat OK")
+ }
+ // For heartbeat, always return silent - the subagent result will be
+ // sent to user via processSystemMessage when the async task completes
+ return tools.SilentResult(response)
+ })
+
+ channelManager, err := channels.NewManager(cfg, msgBus)
+ if err != nil {
+ fmt.Printf("Error creating channel manager: %v\n", err)
+ os.Exit(1)
+ }
+
+ // Inject channel manager into agent loop for command handling
+ agentLoop.SetChannelManager(channelManager)
+
+ var transcriber *voice.GroqTranscriber
+ groqAPIKey := cfg.Providers.Groq.APIKey
+ if groqAPIKey == "" {
+ for _, mc := range cfg.ModelList {
+ if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
+ groqAPIKey = mc.APIKey
+ break
+ }
+ }
+ }
+ if groqAPIKey != "" {
+ transcriber = voice.NewGroqTranscriber(groqAPIKey)
+ logger.InfoC("voice", "Groq voice transcription enabled")
+ }
+
+ if transcriber != nil {
+ if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
+ if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
+ tc.SetTranscriber(transcriber)
+ logger.InfoC("voice", "Groq transcription attached to Telegram channel")
+ }
+ }
+ if discordChannel, ok := channelManager.GetChannel("discord"); ok {
+ if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
+ dc.SetTranscriber(transcriber)
+ logger.InfoC("voice", "Groq transcription attached to Discord channel")
+ }
+ }
+ if slackChannel, ok := channelManager.GetChannel("slack"); ok {
+ if sc, ok := slackChannel.(*channels.SlackChannel); ok {
+ sc.SetTranscriber(transcriber)
+ logger.InfoC("voice", "Groq transcription attached to Slack channel")
+ }
+ }
+ }
+
+ enabledChannels := channelManager.GetEnabledChannels()
+ if len(enabledChannels) > 0 {
+ fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
+ } else {
+ fmt.Println("⚠ Warning: No channels enabled")
+ }
+
+ fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
+ fmt.Println("Press Ctrl+C to stop")
+
+ ctx, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ if err := cronService.Start(); err != nil {
+ fmt.Printf("Error starting cron service: %v\n", err)
+ }
+ fmt.Println("✓ Cron service started")
+
+ if err := heartbeatService.Start(); err != nil {
+ fmt.Printf("Error starting heartbeat service: %v\n", err)
+ }
+ fmt.Println("✓ Heartbeat service started")
+
+ stateManager := state.NewManager(cfg.WorkspacePath())
+ deviceService := devices.NewService(devices.Config{
+ Enabled: cfg.Devices.Enabled,
+ MonitorUSB: cfg.Devices.MonitorUSB,
+ }, stateManager)
+ deviceService.SetBus(msgBus)
+ if err := deviceService.Start(ctx); err != nil {
+ fmt.Printf("Error starting device service: %v\n", err)
+ } else if cfg.Devices.Enabled {
+ fmt.Println("✓ Device event service started")
+ }
+
+ if err := channelManager.StartAll(ctx); err != nil {
+ fmt.Printf("Error starting channels: %v\n", err)
+ }
+
+ healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
+ go func() {
+ if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
+ logger.ErrorCF("health", "Health server error", map[string]any{"error": err.Error()})
+ }
+ }()
+ fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
+
+ go agentLoop.Run(ctx)
+
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, os.Interrupt)
+ <-sigChan
+
+ fmt.Println("\nShutting down...")
+ cancel()
+ healthServer.Stop(context.Background())
+ deviceService.Stop()
+ heartbeatService.Stop()
+ cronService.Stop()
+ agentLoop.Stop()
+ channelManager.StopAll(ctx)
+ fmt.Println("✓ Gateway stopped")
+}
+
+func setupCronTool(
+ agentLoop *agent.AgentLoop,
+ msgBus *bus.MessageBus,
+ workspace string,
+ restrict bool,
+ execTimeout time.Duration,
+ cfg *config.Config,
+) *cron.CronService {
+ cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
+
+ // Create cron service
+ cronService := cron.NewCronService(cronStorePath, nil)
+
+ // Create and register CronTool
+ cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, cfg)
+ agentLoop.RegisterTool(cronTool)
+
+ // Set the onJob handler
+ cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
+ result := cronTool.ExecuteJob(context.Background(), job)
+ return result, nil
+ })
+
+ return cronService
+}
diff --git a/cmd/picoclaw/cmd_migrate.go b/cmd/picoclaw/cmd_migrate.go
new file mode 100644
index 000000000..86d4903ef
--- /dev/null
+++ b/cmd/picoclaw/cmd_migrate.go
@@ -0,0 +1,81 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/sipeed/picoclaw/pkg/migrate"
+)
+
+func migrateCmd() {
+ if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
+ migrateHelp()
+ return
+ }
+
+ opts := migrate.Options{}
+
+ args := os.Args[2:]
+ for i := 0; i < len(args); i++ {
+ switch args[i] {
+ case "--dry-run":
+ opts.DryRun = true
+ case "--config-only":
+ opts.ConfigOnly = true
+ case "--workspace-only":
+ opts.WorkspaceOnly = true
+ case "--force":
+ opts.Force = true
+ case "--refresh":
+ opts.Refresh = true
+ case "--openclaw-home":
+ if i+1 < len(args) {
+ opts.OpenClawHome = args[i+1]
+ i++
+ }
+ case "--picoclaw-home":
+ if i+1 < len(args) {
+ opts.PicoClawHome = args[i+1]
+ i++
+ }
+ default:
+ fmt.Printf("Unknown flag: %s\n", args[i])
+ migrateHelp()
+ os.Exit(1)
+ }
+ }
+
+ result, err := migrate.Run(opts)
+ if err != nil {
+ fmt.Printf("Error: %v\n", err)
+ os.Exit(1)
+ }
+
+ if !opts.DryRun {
+ migrate.PrintSummary(result)
+ }
+}
+
+func migrateHelp() {
+ fmt.Println("\nMigrate from OpenClaw to PicoClaw")
+ fmt.Println()
+ fmt.Println("Usage: picoclaw migrate [options]")
+ fmt.Println()
+ fmt.Println("Options:")
+ fmt.Println(" --dry-run Show what would be migrated without making changes")
+ fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
+ fmt.Println(" --config-only Only migrate config, skip workspace files")
+ fmt.Println(" --workspace-only Only migrate workspace files, skip config")
+ fmt.Println(" --force Skip confirmation prompts")
+ fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
+ fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
+ fmt.Println()
+ fmt.Println("Examples:")
+ fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
+ fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
+ fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
+ fmt.Println(" picoclaw migrate --force Migrate without confirmation")
+}
diff --git a/cmd/picoclaw/cmd_onboard.go b/cmd/picoclaw/cmd_onboard.go
new file mode 100644
index 000000000..1a9ebad61
--- /dev/null
+++ b/cmd/picoclaw/cmd_onboard.go
@@ -0,0 +1,108 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "embed"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+//go:generate cp -r ../../workspace .
+//go:embed workspace
+var embeddedFiles embed.FS
+
+func onboard() {
+ configPath := getConfigPath()
+
+ if _, err := os.Stat(configPath); err == nil {
+ fmt.Printf("Config already exists at %s\n", configPath)
+ fmt.Print("Overwrite? (y/n): ")
+ var response string
+ fmt.Scanln(&response)
+ if response != "y" {
+ fmt.Println("Aborted.")
+ return
+ }
+ }
+
+ cfg := config.DefaultConfig()
+ if err := config.SaveConfig(configPath, cfg); err != nil {
+ fmt.Printf("Error saving config: %v\n", err)
+ os.Exit(1)
+ }
+
+ workspace := cfg.WorkspacePath()
+ createWorkspaceTemplates(workspace)
+
+ fmt.Printf("%s picoclaw is ready!\n", logo)
+ fmt.Println("\nNext steps:")
+ fmt.Println(" 1. Add your API key to", configPath)
+ fmt.Println("")
+ fmt.Println(" Recommended:")
+ fmt.Println(" - OpenRouter: https://openrouter.ai/keys (access 100+ models)")
+ fmt.Println(" - Ollama: https://ollama.com (local, free)")
+ fmt.Println("")
+ fmt.Println(" See README.md for 17+ supported providers.")
+ fmt.Println("")
+ fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
+}
+
+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)
+ }
+
+ // 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)
+ }
+
+ // 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
+}
+
+func createWorkspaceTemplates(workspace string) {
+ err := copyEmbeddedToTarget(workspace)
+ if err != nil {
+ fmt.Printf("Error copying workspace templates: %v\n", err)
+ }
+}
diff --git a/cmd/picoclaw/cmd_skills.go b/cmd/picoclaw/cmd_skills.go
new file mode 100644
index 000000000..0814494b3
--- /dev/null
+++ b/cmd/picoclaw/cmd_skills.go
@@ -0,0 +1,305 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/skills"
+ "github.com/sipeed/picoclaw/pkg/utils"
+)
+
+func skillsHelp() {
+ fmt.Println("\nSkills commands:")
+ fmt.Println(" list List installed skills")
+ fmt.Println(" install Install skill from GitHub")
+ fmt.Println(" install-builtin Install all builtin skills to workspace")
+ fmt.Println(" list-builtin List available builtin skills")
+ fmt.Println(" remove Remove installed skill")
+ fmt.Println(" search Search available skills")
+ fmt.Println(" show Show skill details")
+ fmt.Println()
+ fmt.Println("Examples:")
+ fmt.Println(" picoclaw skills list")
+ fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
+ fmt.Println(" picoclaw skills install-builtin")
+ fmt.Println(" picoclaw skills list-builtin")
+ fmt.Println(" picoclaw skills remove weather")
+ fmt.Println(" picoclaw skills install --registry clawhub github")
+}
+
+func skillsListCmd(loader *skills.SkillsLoader) {
+ allSkills := loader.ListSkills()
+
+ if len(allSkills) == 0 {
+ fmt.Println("No skills installed.")
+ return
+ }
+
+ fmt.Println("\nInstalled Skills:")
+ fmt.Println("------------------")
+ for _, skill := range allSkills {
+ fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
+ if skill.Description != "" {
+ fmt.Printf(" %s\n", skill.Description)
+ }
+ }
+}
+
+func skillsInstallCmd(installer *skills.SkillInstaller, cfg *config.Config) {
+ if len(os.Args) < 4 {
+ fmt.Println("Usage: picoclaw skills install ")
+ fmt.Println(" picoclaw skills install --registry ")
+ return
+ }
+
+ // Check for --registry flag.
+ if os.Args[3] == "--registry" {
+ if len(os.Args) < 6 {
+ fmt.Println("Usage: picoclaw skills install --registry ")
+ fmt.Println("Example: picoclaw skills install --registry clawhub github")
+ return
+ }
+ registryName := os.Args[4]
+ slug := os.Args[5]
+ skillsInstallFromRegistry(cfg, registryName, slug)
+ return
+ }
+
+ // Default: install from GitHub (backward compatible).
+ repo := os.Args[3]
+ fmt.Printf("Installing skill from %s...\n", repo)
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ if err := installer.InstallFromGitHub(ctx, repo); err != nil {
+ fmt.Printf("\u2717 Failed to install skill: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo))
+}
+
+// skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub).
+func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) {
+ err := utils.ValidateSkillIdentifier(registryName)
+ if err != nil {
+ fmt.Printf("\u2717 Invalid registry name: %v\n", err)
+ os.Exit(1)
+ }
+
+ err = utils.ValidateSkillIdentifier(slug)
+ if err != nil {
+ fmt.Printf("\u2717 Invalid slug: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("Installing skill '%s' from %s registry...\n", slug, registryName)
+
+ registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
+ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
+ ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
+ })
+
+ registry := registryMgr.GetRegistry(registryName)
+ if registry == nil {
+ fmt.Printf("\u2717 Registry '%s' not found or not enabled. Check your config.json.\n", registryName)
+ os.Exit(1)
+ }
+
+ workspace := cfg.WorkspacePath()
+ targetDir := filepath.Join(workspace, "skills", slug)
+
+ if _, err = os.Stat(targetDir); err == nil {
+ fmt.Printf("\u2717 Skill '%s' already installed at %s\n", slug, targetDir)
+ os.Exit(1)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+ defer cancel()
+
+ if err = os.MkdirAll(filepath.Join(workspace, "skills"), 0o755); err != nil {
+ fmt.Printf("\u2717 Failed to create skills directory: %v\n", err)
+ os.Exit(1)
+ }
+
+ result, err := registry.DownloadAndInstall(ctx, slug, "", targetDir)
+ if err != nil {
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
+ }
+ fmt.Printf("\u2717 Failed to install skill: %v\n", err)
+ os.Exit(1)
+ }
+
+ if result.IsMalwareBlocked {
+ rmErr := os.RemoveAll(targetDir)
+ if rmErr != nil {
+ fmt.Printf("\u2717 Failed to remove partial install: %v\n", rmErr)
+ }
+ fmt.Printf("\u2717 Skill '%s' is flagged as malicious and cannot be installed.\n", slug)
+ os.Exit(1)
+ }
+
+ if result.IsSuspicious {
+ fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug)
+ }
+
+ fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version)
+ if result.Summary != "" {
+ fmt.Printf(" %s\n", result.Summary)
+ }
+}
+
+func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
+ fmt.Printf("Removing skill '%s'...\n", skillName)
+
+ if err := installer.Uninstall(skillName); err != nil {
+ fmt.Printf("✗ Failed to remove skill: %v\n", err)
+ os.Exit(1)
+ }
+
+ fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
+}
+
+func skillsInstallBuiltinCmd(workspace string) {
+ builtinSkillsDir := "./picoclaw/skills"
+ workspaceSkillsDir := filepath.Join(workspace, "skills")
+
+ fmt.Printf("Copying builtin skills to workspace...\n")
+
+ skillsToInstall := []string{
+ "weather",
+ "news",
+ "stock",
+ "calculator",
+ }
+
+ for _, skillName := range skillsToInstall {
+ builtinPath := filepath.Join(builtinSkillsDir, skillName)
+ workspacePath := filepath.Join(workspaceSkillsDir, skillName)
+
+ if _, err := os.Stat(builtinPath); err != nil {
+ fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
+ continue
+ }
+
+ if err := os.MkdirAll(workspacePath, 0o755); err != nil {
+ fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
+ continue
+ }
+
+ if err := copyDirectory(builtinPath, workspacePath); err != nil {
+ fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
+ }
+ }
+
+ fmt.Println("\n✓ All builtin skills installed!")
+ fmt.Println("Now you can use them in your workspace.")
+}
+
+func skillsListBuiltinCmd() {
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ return
+ }
+ builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
+
+ fmt.Println("\nAvailable Builtin Skills:")
+ fmt.Println("-----------------------")
+
+ entries, err := os.ReadDir(builtinSkillsDir)
+ if err != nil {
+ fmt.Printf("Error reading builtin skills: %v\n", err)
+ return
+ }
+
+ if len(entries) == 0 {
+ fmt.Println("No builtin skills available.")
+ return
+ }
+
+ for _, entry := range entries {
+ if entry.IsDir() {
+ skillName := entry.Name()
+ skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
+
+ description := "No description"
+ if _, err := os.Stat(skillFile); err == nil {
+ data, err := os.ReadFile(skillFile)
+ if err == nil {
+ content := string(data)
+ if idx := strings.Index(content, "\n"); idx > 0 {
+ firstLine := content[:idx]
+ if strings.Contains(firstLine, "description:") {
+ descLine := strings.Index(content[idx:], "\n")
+ if descLine > 0 {
+ description = strings.TrimSpace(content[idx+descLine : idx+descLine])
+ }
+ }
+ }
+ }
+ }
+ status := "✓"
+ fmt.Printf(" %s %s\n", status, entry.Name())
+ if description != "" {
+ fmt.Printf(" %s\n", description)
+ }
+ }
+ }
+}
+
+func skillsSearchCmd(installer *skills.SkillInstaller) {
+ fmt.Println("Searching for available skills...")
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ availableSkills, err := installer.ListAvailableSkills(ctx)
+ if err != nil {
+ fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
+ return
+ }
+
+ if len(availableSkills) == 0 {
+ fmt.Println("No skills available.")
+ return
+ }
+
+ fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
+ fmt.Println("--------------------")
+ for _, skill := range availableSkills {
+ fmt.Printf(" 📦 %s\n", skill.Name)
+ fmt.Printf(" %s\n", skill.Description)
+ fmt.Printf(" Repo: %s\n", skill.Repository)
+ if skill.Author != "" {
+ fmt.Printf(" Author: %s\n", skill.Author)
+ }
+ if len(skill.Tags) > 0 {
+ fmt.Printf(" Tags: %v\n", skill.Tags)
+ }
+ fmt.Println()
+ }
+}
+
+func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
+ content, ok := loader.LoadSkill(skillName)
+ if !ok {
+ fmt.Printf("✗ Skill '%s' not found\n", skillName)
+ return
+ }
+
+ fmt.Printf("\n📦 Skill: %s\n", skillName)
+ fmt.Println("----------------------")
+ fmt.Println(content)
+}
diff --git a/cmd/picoclaw/cmd_status.go b/cmd/picoclaw/cmd_status.go
new file mode 100644
index 000000000..6a117bd17
--- /dev/null
+++ b/cmd/picoclaw/cmd_status.go
@@ -0,0 +1,102 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "github.com/sipeed/picoclaw/pkg/auth"
+)
+
+func statusCmd() {
+ cfg, err := loadConfig()
+ if err != nil {
+ fmt.Printf("Error loading config: %v\n", err)
+ return
+ }
+
+ configPath := getConfigPath()
+
+ fmt.Printf("%s picoclaw Status\n", logo)
+ fmt.Printf("Version: %s\n", formatVersion())
+ build, _ := formatBuildInfo()
+ if build != "" {
+ fmt.Printf("Build: %s\n", build)
+ }
+ fmt.Println()
+
+ if _, err := os.Stat(configPath); err == nil {
+ fmt.Println("Config:", configPath, "✓")
+ } else {
+ fmt.Println("Config:", configPath, "✗")
+ }
+
+ workspace := cfg.WorkspacePath()
+ if _, err := os.Stat(workspace); err == nil {
+ fmt.Println("Workspace:", workspace, "✓")
+ } else {
+ fmt.Println("Workspace:", workspace, "✗")
+ }
+
+ if _, err := os.Stat(configPath); err == nil {
+ fmt.Printf("Model: %s\n", cfg.Agents.Defaults.GetModelName())
+
+ hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
+ hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
+ hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
+ hasGemini := cfg.Providers.Gemini.APIKey != ""
+ hasZhipu := cfg.Providers.Zhipu.APIKey != ""
+ hasQwen := cfg.Providers.Qwen.APIKey != ""
+ hasGroq := cfg.Providers.Groq.APIKey != ""
+ hasVLLM := cfg.Providers.VLLM.APIBase != ""
+ hasMoonshot := cfg.Providers.Moonshot.APIKey != ""
+ hasDeepSeek := cfg.Providers.DeepSeek.APIKey != ""
+ hasVolcEngine := cfg.Providers.VolcEngine.APIKey != ""
+ hasNvidia := cfg.Providers.Nvidia.APIKey != ""
+ hasOllama := cfg.Providers.Ollama.APIBase != ""
+
+ status := func(enabled bool) string {
+ if enabled {
+ return "✓"
+ }
+ return "not set"
+ }
+ fmt.Println("OpenRouter API:", status(hasOpenRouter))
+ fmt.Println("Anthropic API:", status(hasAnthropic))
+ fmt.Println("OpenAI API:", status(hasOpenAI))
+ fmt.Println("Gemini API:", status(hasGemini))
+ fmt.Println("Zhipu API:", status(hasZhipu))
+ fmt.Println("Qwen API:", status(hasQwen))
+ fmt.Println("Groq API:", status(hasGroq))
+ fmt.Println("Moonshot API:", status(hasMoonshot))
+ fmt.Println("DeepSeek API:", status(hasDeepSeek))
+ fmt.Println("VolcEngine API:", status(hasVolcEngine))
+ fmt.Println("Nvidia API:", status(hasNvidia))
+ if hasVLLM {
+ fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
+ } else {
+ fmt.Println("vLLM/Local: not set")
+ }
+ if hasOllama {
+ fmt.Printf("Ollama: ✓ %s\n", cfg.Providers.Ollama.APIBase)
+ } else {
+ fmt.Println("Ollama: not set")
+ }
+
+ store, _ := auth.LoadStore()
+ if store != nil && len(store.Credentials) > 0 {
+ fmt.Println("\nOAuth/Token Auth:")
+ for provider, cred := range store.Credentials {
+ status := "authenticated"
+ if cred.IsExpired() {
+ status = "expired"
+ } else if cred.NeedsRefresh() {
+ status = "needs refresh"
+ }
+ fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
+ }
+ }
+ }
+}
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 2129662d7..25ad701ca 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -7,41 +7,16 @@
package main
import (
- "bufio"
- "context"
- "embed"
"fmt"
"io"
- "io/fs"
"os"
- "os/signal"
"path/filepath"
"runtime"
- "strings"
- "time"
- "github.com/chzyer/readline"
- "github.com/sipeed/picoclaw/pkg/agent"
- "github.com/sipeed/picoclaw/pkg/auth"
- "github.com/sipeed/picoclaw/pkg/bus"
- "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
- "github.com/sipeed/picoclaw/pkg/cron"
- "github.com/sipeed/picoclaw/pkg/devices"
- "github.com/sipeed/picoclaw/pkg/heartbeat"
- "github.com/sipeed/picoclaw/pkg/logger"
- "github.com/sipeed/picoclaw/pkg/migrate"
- "github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
- "github.com/sipeed/picoclaw/pkg/state"
- "github.com/sipeed/picoclaw/pkg/tools"
- "github.com/sipeed/picoclaw/pkg/voice"
)
-//go:generate cp -r ../../workspace .
-//go:embed workspace
-var embeddedFiles embed.FS
-
var (
version = "dev"
gitCommit string
@@ -156,7 +131,7 @@ func main() {
workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace)
- // 获取全局配置目录和内置 skills 目录
+ // get global config directory and builtin skills directory
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
@@ -166,7 +141,7 @@ func main() {
case "list":
skillsListCmd(skillsLoader)
case "install":
- skillsInstallCmd(installer)
+ skillsInstallCmd(installer, cfg)
case "remove", "uninstall":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills remove ")
@@ -214,1199 +189,11 @@ func printHelp() {
fmt.Println(" version Show version information")
}
-func onboard() {
- configPath := getConfigPath()
-
- if _, err := os.Stat(configPath); err == nil {
- fmt.Printf("Config already exists at %s\n", configPath)
- fmt.Print("Overwrite? (y/n): ")
- var response string
- fmt.Scanln(&response)
- if response != "y" {
- fmt.Println("Aborted.")
- return
- }
- }
-
- cfg := config.DefaultConfig()
- if err := config.SaveConfig(configPath, cfg); err != nil {
- fmt.Printf("Error saving config: %v\n", err)
- os.Exit(1)
- }
-
- workspace := cfg.WorkspacePath()
- createWorkspaceTemplates(workspace)
-
- fmt.Printf("%s picoclaw is ready!\n", logo)
- fmt.Println("\nNext steps:")
- fmt.Println(" 1. Add your API key to", configPath)
- fmt.Println(" Get one at: https://openrouter.ai/keys")
- fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
-}
-
-func copyEmbeddedToTarget(targetDir string) error {
- // Ensure target directory exists
- if err := os.MkdirAll(targetDir, 0755); err != nil {
- return fmt.Errorf("Failed to create target directory: %w", err)
- }
-
- // Walk through all files in embed.FS
- err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
-
- // Skip directories
- if d.IsDir() {
- return nil
- }
-
- // Read embedded file
- data, err := embeddedFiles.ReadFile(path)
- if err != nil {
- return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
- }
-
- new_path, err := filepath.Rel("workspace", path)
- if err != nil {
- return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
- }
-
- // Build target file path
- targetPath := filepath.Join(targetDir, new_path)
-
- // Ensure target file's directory exists
- if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
- return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
- }
-
- // Write file
- if err := os.WriteFile(targetPath, data, 0644); err != nil {
- return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
- }
-
- return nil
- })
-
- return err
-}
-
-func createWorkspaceTemplates(workspace string) {
- err := copyEmbeddedToTarget(workspace)
- if err != nil {
- fmt.Printf("Error copying workspace templates: %v\n", err)
- }
-}
-
-func migrateCmd() {
- if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") {
- migrateHelp()
- return
- }
-
- opts := migrate.Options{}
-
- args := os.Args[2:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--dry-run":
- opts.DryRun = true
- case "--config-only":
- opts.ConfigOnly = true
- case "--workspace-only":
- opts.WorkspaceOnly = true
- case "--force":
- opts.Force = true
- case "--refresh":
- opts.Refresh = true
- case "--openclaw-home":
- if i+1 < len(args) {
- opts.OpenClawHome = args[i+1]
- i++
- }
- case "--picoclaw-home":
- if i+1 < len(args) {
- opts.PicoClawHome = args[i+1]
- i++
- }
- default:
- fmt.Printf("Unknown flag: %s\n", args[i])
- migrateHelp()
- os.Exit(1)
- }
- }
-
- result, err := migrate.Run(opts)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- os.Exit(1)
- }
-
- if !opts.DryRun {
- migrate.PrintSummary(result)
- }
-}
-
-func migrateHelp() {
- fmt.Println("\nMigrate from OpenClaw to PicoClaw")
- fmt.Println()
- fmt.Println("Usage: picoclaw migrate [options]")
- fmt.Println()
- fmt.Println("Options:")
- fmt.Println(" --dry-run Show what would be migrated without making changes")
- fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)")
- fmt.Println(" --config-only Only migrate config, skip workspace files")
- fmt.Println(" --workspace-only Only migrate workspace files, skip config")
- fmt.Println(" --force Skip confirmation prompts")
- fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)")
- fmt.Println(" --picoclaw-home Override PicoClaw home directory (default: ~/.picoclaw)")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw migrate Detect and migrate from OpenClaw")
- fmt.Println(" picoclaw migrate --dry-run Show what would be migrated")
- fmt.Println(" picoclaw migrate --refresh Re-sync workspace files")
- fmt.Println(" picoclaw migrate --force Migrate without confirmation")
-}
-
-func agentCmd() {
- message := ""
- sessionKey := "cli:default"
-
- args := os.Args[2:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--debug", "-d":
- logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
- case "-m", "--message":
- if i+1 < len(args) {
- message = args[i+1]
- i++
- }
- case "-s", "--session":
- if i+1 < len(args) {
- sessionKey = args[i+1]
- i++
- }
- }
- }
-
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
- }
-
- provider, err := providers.CreateProvider(cfg)
- if err != nil {
- fmt.Printf("Error creating provider: %v\n", err)
- os.Exit(1)
- }
-
- msgBus := bus.NewMessageBus()
- agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
-
- // Print agent startup info (only for interactive mode)
- startupInfo := agentLoop.GetStartupInfo()
- logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
- "tools_count": startupInfo["tools"].(map[string]interface{})["count"],
- "skills_total": startupInfo["skills"].(map[string]interface{})["total"],
- "skills_available": startupInfo["skills"].(map[string]interface{})["available"],
- })
-
- if message != "" {
- ctx := context.Background()
- response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- os.Exit(1)
- }
- fmt.Printf("\n%s %s\n", logo, response)
- } else {
- fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
- interactiveMode(agentLoop, sessionKey)
- }
-}
-
-func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
- prompt := fmt.Sprintf("%s You: ", logo)
-
- rl, err := readline.NewEx(&readline.Config{
- Prompt: prompt,
- HistoryFile: filepath.Join(os.TempDir(), ".picoclaw_history"),
- HistoryLimit: 100,
- InterruptPrompt: "^C",
- EOFPrompt: "exit",
- })
-
- if err != nil {
- fmt.Printf("Error initializing readline: %v\n", err)
- fmt.Println("Falling back to simple input mode...")
- simpleInteractiveMode(agentLoop, sessionKey)
- return
- }
- defer rl.Close()
-
- for {
- line, err := rl.Readline()
- if err != nil {
- if err == readline.ErrInterrupt || err == io.EOF {
- fmt.Println("\nGoodbye!")
- return
- }
- fmt.Printf("Error reading input: %v\n", err)
- continue
- }
-
- input := strings.TrimSpace(line)
- if input == "" {
- continue
- }
-
- if input == "exit" || input == "quit" {
- fmt.Println("Goodbye!")
- return
- }
-
- ctx := context.Background()
- response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- continue
- }
-
- fmt.Printf("\n%s %s\n\n", logo, response)
- }
-}
-
-func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
- reader := bufio.NewReader(os.Stdin)
- for {
- fmt.Print(fmt.Sprintf("%s You: ", logo))
- line, err := reader.ReadString('\n')
- if err != nil {
- if err == io.EOF {
- fmt.Println("\nGoodbye!")
- return
- }
- fmt.Printf("Error reading input: %v\n", err)
- continue
- }
-
- input := strings.TrimSpace(line)
- if input == "" {
- continue
- }
-
- if input == "exit" || input == "quit" {
- fmt.Println("Goodbye!")
- return
- }
-
- ctx := context.Background()
- response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
- if err != nil {
- fmt.Printf("Error: %v\n", err)
- continue
- }
-
- fmt.Printf("\n%s %s\n\n", logo, response)
- }
-}
-
-func gatewayCmd() {
- // Check for --debug flag
- args := os.Args[2:]
- for _, arg := range args {
- if arg == "--debug" || arg == "-d" {
- logger.SetLevel(logger.DEBUG)
- fmt.Println("🔍 Debug mode enabled")
- break
- }
- }
-
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- os.Exit(1)
- }
-
- provider, err := providers.CreateProvider(cfg)
- if err != nil {
- fmt.Printf("Error creating provider: %v\n", err)
- os.Exit(1)
- }
-
- msgBus := bus.NewMessageBus()
- agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
-
- // Print agent startup info
- fmt.Println("\n📦 Agent Status:")
- startupInfo := agentLoop.GetStartupInfo()
- toolsInfo := startupInfo["tools"].(map[string]interface{})
- skillsInfo := startupInfo["skills"].(map[string]interface{})
- fmt.Printf(" • Tools: %d loaded\n", toolsInfo["count"])
- fmt.Printf(" • Skills: %d/%d available\n",
- skillsInfo["available"],
- skillsInfo["total"])
-
- // Log to file as well
- logger.InfoCF("agent", "Agent initialized",
- map[string]interface{}{
- "tools_count": toolsInfo["count"],
- "skills_total": skillsInfo["total"],
- "skills_available": skillsInfo["available"],
- })
-
- // Setup cron tool and service
- cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath())
-
- heartbeatService := heartbeat.NewHeartbeatService(
- cfg.WorkspacePath(),
- cfg.Heartbeat.Interval,
- cfg.Heartbeat.Enabled,
- )
- heartbeatService.SetBus(msgBus)
- heartbeatService.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
- // Use cli:direct as fallback if no valid channel
- if channel == "" || chatID == "" {
- channel, chatID = "cli", "direct"
- }
- // Use ProcessHeartbeat - no session history, each heartbeat is independent
- response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
- if err != nil {
- return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
- }
- if response == "HEARTBEAT_OK" {
- return tools.SilentResult("Heartbeat OK")
- }
- // For heartbeat, always return silent - the subagent result will be
- // sent to user via processSystemMessage when the async task completes
- return tools.SilentResult(response)
- })
-
- channelManager, err := channels.NewManager(cfg, msgBus)
- if err != nil {
- fmt.Printf("Error creating channel manager: %v\n", err)
- os.Exit(1)
- }
-
- var transcriber *voice.GroqTranscriber
- if cfg.Providers.Groq.APIKey != "" {
- transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey)
- logger.InfoC("voice", "Groq voice transcription enabled")
- }
-
- if transcriber != nil {
- if telegramChannel, ok := channelManager.GetChannel("telegram"); ok {
- if tc, ok := telegramChannel.(*channels.TelegramChannel); ok {
- tc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to Telegram channel")
- }
- }
- if discordChannel, ok := channelManager.GetChannel("discord"); ok {
- if dc, ok := discordChannel.(*channels.DiscordChannel); ok {
- dc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to Discord channel")
- }
- }
- if slackChannel, ok := channelManager.GetChannel("slack"); ok {
- if sc, ok := slackChannel.(*channels.SlackChannel); ok {
- sc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to Slack channel")
- }
- }
- }
-
- enabledChannels := channelManager.GetEnabledChannels()
- if len(enabledChannels) > 0 {
- fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
- } else {
- fmt.Println("⚠ Warning: No channels enabled")
- }
-
- fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
- fmt.Println("Press Ctrl+C to stop")
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- if err := cronService.Start(); err != nil {
- fmt.Printf("Error starting cron service: %v\n", err)
- }
- fmt.Println("✓ Cron service started")
-
- if err := heartbeatService.Start(); err != nil {
- fmt.Printf("Error starting heartbeat service: %v\n", err)
- }
- fmt.Println("✓ Heartbeat service started")
-
- stateManager := state.NewManager(cfg.WorkspacePath())
- deviceService := devices.NewService(devices.Config{
- Enabled: cfg.Devices.Enabled,
- MonitorUSB: cfg.Devices.MonitorUSB,
- }, stateManager)
- deviceService.SetBus(msgBus)
- if err := deviceService.Start(ctx); err != nil {
- fmt.Printf("Error starting device service: %v\n", err)
- } else if cfg.Devices.Enabled {
- fmt.Println("✓ Device event service started")
- }
-
- if err := channelManager.StartAll(ctx); err != nil {
- fmt.Printf("Error starting channels: %v\n", err)
- }
-
- go agentLoop.Run(ctx)
-
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, os.Interrupt)
- <-sigChan
-
- fmt.Println("\nShutting down...")
- cancel()
- deviceService.Stop()
- heartbeatService.Stop()
- cronService.Stop()
- agentLoop.Stop()
- channelManager.StopAll(ctx)
- fmt.Println("✓ Gateway stopped")
-}
-
-func statusCmd() {
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- return
- }
-
- configPath := getConfigPath()
-
- fmt.Printf("%s picoclaw Status\n", logo)
- fmt.Printf("Version: %s\n", formatVersion())
- build, _ := formatBuildInfo()
- if build != "" {
- fmt.Printf("Build: %s\n", build)
- }
- fmt.Println()
-
- if _, err := os.Stat(configPath); err == nil {
- fmt.Println("Config:", configPath, "✓")
- } else {
- fmt.Println("Config:", configPath, "✗")
- }
-
- workspace := cfg.WorkspacePath()
- if _, err := os.Stat(workspace); err == nil {
- fmt.Println("Workspace:", workspace, "✓")
- } else {
- fmt.Println("Workspace:", workspace, "✗")
- }
-
- if _, err := os.Stat(configPath); err == nil {
- fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model)
-
- hasOpenRouter := cfg.Providers.OpenRouter.APIKey != ""
- hasAnthropic := cfg.Providers.Anthropic.APIKey != ""
- hasOpenAI := cfg.Providers.OpenAI.APIKey != ""
- hasGemini := cfg.Providers.Gemini.APIKey != ""
- hasZhipu := cfg.Providers.Zhipu.APIKey != ""
- hasGroq := cfg.Providers.Groq.APIKey != ""
- hasVLLM := cfg.Providers.VLLM.APIBase != ""
-
- status := func(enabled bool) string {
- if enabled {
- return "✓"
- }
- return "not set"
- }
- fmt.Println("OpenRouter API:", status(hasOpenRouter))
- fmt.Println("Anthropic API:", status(hasAnthropic))
- fmt.Println("OpenAI API:", status(hasOpenAI))
- fmt.Println("Gemini API:", status(hasGemini))
- fmt.Println("Zhipu API:", status(hasZhipu))
- fmt.Println("Groq API:", status(hasGroq))
- if hasVLLM {
- fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase)
- } else {
- fmt.Println("vLLM/Local: not set")
- }
-
- store, _ := auth.LoadStore()
- if store != nil && len(store.Credentials) > 0 {
- fmt.Println("\nOAuth/Token Auth:")
- for provider, cred := range store.Credentials {
- status := "authenticated"
- if cred.IsExpired() {
- status = "expired"
- } else if cred.NeedsRefresh() {
- status = "needs refresh"
- }
- fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status)
- }
- }
- }
-}
-
-func authCmd() {
- if len(os.Args) < 3 {
- authHelp()
- return
- }
-
- switch os.Args[2] {
- case "login":
- authLoginCmd()
- case "logout":
- authLogoutCmd()
- case "status":
- authStatusCmd()
- default:
- fmt.Printf("Unknown auth command: %s\n", os.Args[2])
- authHelp()
- }
-}
-
-func authHelp() {
- fmt.Println("\nAuth commands:")
- fmt.Println(" login Login via OAuth or paste token")
- fmt.Println(" logout Remove stored credentials")
- fmt.Println(" status Show current auth status")
- fmt.Println()
- fmt.Println("Login options:")
- fmt.Println(" --provider Provider to login with (openai, anthropic)")
- fmt.Println(" --device-code Use device code flow (for headless environments)")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw auth login --provider openai")
- fmt.Println(" picoclaw auth login --provider openai --device-code")
- fmt.Println(" picoclaw auth login --provider anthropic")
- fmt.Println(" picoclaw auth logout --provider openai")
- fmt.Println(" picoclaw auth status")
-}
-
-func authLoginCmd() {
- provider := ""
- useDeviceCode := false
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--provider", "-p":
- if i+1 < len(args) {
- provider = args[i+1]
- i++
- }
- case "--device-code":
- useDeviceCode = true
- }
- }
-
- if provider == "" {
- fmt.Println("Error: --provider is required")
- fmt.Println("Supported providers: openai, anthropic")
- return
- }
-
- switch provider {
- case "openai":
- authLoginOpenAI(useDeviceCode)
- case "anthropic":
- authLoginPasteToken(provider)
- default:
- fmt.Printf("Unsupported provider: %s\n", provider)
- fmt.Println("Supported providers: openai, anthropic")
- }
-}
-
-func authLoginOpenAI(useDeviceCode bool) {
- cfg := auth.OpenAIOAuthConfig()
-
- var cred *auth.AuthCredential
- var err error
-
- if useDeviceCode {
- cred, err = auth.LoginDeviceCode(cfg)
- } else {
- cred, err = auth.LoginBrowser(cfg)
- }
-
- if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
- }
-
- if err := auth.SetCredential("openai", cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
- }
-
- appCfg, err := loadConfig()
- if err == nil {
- appCfg.Providers.OpenAI.AuthMethod = "oauth"
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
- fmt.Printf("Warning: could not update config: %v\n", err)
- }
- }
-
- fmt.Println("Login successful!")
- if cred.AccountID != "" {
- fmt.Printf("Account: %s\n", cred.AccountID)
- }
-}
-
-func authLoginPasteToken(provider string) {
- cred, err := auth.LoginPasteToken(provider, os.Stdin)
- if err != nil {
- fmt.Printf("Login failed: %v\n", err)
- os.Exit(1)
- }
-
- if err := auth.SetCredential(provider, cred); err != nil {
- fmt.Printf("Failed to save credentials: %v\n", err)
- os.Exit(1)
- }
-
- appCfg, err := loadConfig()
- if err == nil {
- switch provider {
- case "anthropic":
- appCfg.Providers.Anthropic.AuthMethod = "token"
- case "openai":
- appCfg.Providers.OpenAI.AuthMethod = "token"
- }
- if err := config.SaveConfig(getConfigPath(), appCfg); err != nil {
- fmt.Printf("Warning: could not update config: %v\n", err)
- }
- }
-
- fmt.Printf("Token saved for %s!\n", provider)
-}
-
-func authLogoutCmd() {
- provider := ""
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "--provider", "-p":
- if i+1 < len(args) {
- provider = args[i+1]
- i++
- }
- }
- }
-
- if provider != "" {
- if err := auth.DeleteCredential(provider); err != nil {
- fmt.Printf("Failed to remove credentials: %v\n", err)
- os.Exit(1)
- }
-
- appCfg, err := loadConfig()
- if err == nil {
- switch provider {
- case "openai":
- appCfg.Providers.OpenAI.AuthMethod = ""
- case "anthropic":
- appCfg.Providers.Anthropic.AuthMethod = ""
- }
- config.SaveConfig(getConfigPath(), appCfg)
- }
-
- fmt.Printf("Logged out from %s\n", provider)
- } else {
- if err := auth.DeleteAllCredentials(); err != nil {
- fmt.Printf("Failed to remove credentials: %v\n", err)
- os.Exit(1)
- }
-
- appCfg, err := loadConfig()
- if err == nil {
- appCfg.Providers.OpenAI.AuthMethod = ""
- appCfg.Providers.Anthropic.AuthMethod = ""
- config.SaveConfig(getConfigPath(), appCfg)
- }
-
- fmt.Println("Logged out from all providers")
- }
-}
-
-func authStatusCmd() {
- store, err := auth.LoadStore()
- if err != nil {
- fmt.Printf("Error loading auth store: %v\n", err)
- return
- }
-
- if len(store.Credentials) == 0 {
- fmt.Println("No authenticated providers.")
- fmt.Println("Run: picoclaw auth login --provider ")
- return
- }
-
- fmt.Println("\nAuthenticated Providers:")
- fmt.Println("------------------------")
- for provider, cred := range store.Credentials {
- status := "active"
- if cred.IsExpired() {
- status = "expired"
- } else if cred.NeedsRefresh() {
- status = "needs refresh"
- }
-
- fmt.Printf(" %s:\n", provider)
- fmt.Printf(" Method: %s\n", cred.AuthMethod)
- fmt.Printf(" Status: %s\n", status)
- if cred.AccountID != "" {
- fmt.Printf(" Account: %s\n", cred.AccountID)
- }
- if !cred.ExpiresAt.IsZero() {
- fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04"))
- }
- }
-}
-
func getConfigPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".picoclaw", "config.json")
}
-func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService {
- cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
-
- // Create cron service
- cronService := cron.NewCronService(cronStorePath, nil)
-
- // Create and register CronTool
- cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace)
- agentLoop.RegisterTool(cronTool)
-
- // Set the onJob handler
- cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
- result := cronTool.ExecuteJob(context.Background(), job)
- return result, nil
- })
-
- return cronService
-}
-
func loadConfig() (*config.Config, error) {
return config.LoadConfig(getConfigPath())
}
-
-func cronCmd() {
- if len(os.Args) < 3 {
- cronHelp()
- return
- }
-
- subcommand := os.Args[2]
-
- // Load config to get workspace path
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- return
- }
-
- cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
-
- switch subcommand {
- case "list":
- cronListCmd(cronStorePath)
- case "add":
- cronAddCmd(cronStorePath)
- case "remove":
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw cron remove ")
- return
- }
- cronRemoveCmd(cronStorePath, os.Args[3])
- case "enable":
- cronEnableCmd(cronStorePath, false)
- case "disable":
- cronEnableCmd(cronStorePath, true)
- default:
- fmt.Printf("Unknown cron command: %s\n", subcommand)
- cronHelp()
- }
-}
-
-func cronHelp() {
- fmt.Println("\nCron commands:")
- fmt.Println(" list List all scheduled jobs")
- fmt.Println(" add Add a new scheduled job")
- fmt.Println(" remove Remove a job by ID")
- fmt.Println(" enable Enable a job")
- fmt.Println(" disable Disable a job")
- fmt.Println()
- fmt.Println("Add options:")
- fmt.Println(" -n, --name Job name")
- fmt.Println(" -m, --message Message for agent")
- fmt.Println(" -e, --every Run every N seconds")
- fmt.Println(" -c, --cron Cron expression (e.g. '0 9 * * *')")
- fmt.Println(" -d, --deliver Deliver response to channel")
- fmt.Println(" --to Recipient for delivery")
- fmt.Println(" --channel Channel for delivery")
-}
-
-func cronListCmd(storePath string) {
- cs := cron.NewCronService(storePath, nil)
- jobs := cs.ListJobs(true) // Show all jobs, including disabled
-
- if len(jobs) == 0 {
- fmt.Println("No scheduled jobs.")
- return
- }
-
- fmt.Println("\nScheduled Jobs:")
- fmt.Println("----------------")
- for _, job := range jobs {
- var schedule string
- if job.Schedule.Kind == "every" && job.Schedule.EveryMS != nil {
- schedule = fmt.Sprintf("every %ds", *job.Schedule.EveryMS/1000)
- } else if job.Schedule.Kind == "cron" {
- schedule = job.Schedule.Expr
- } else {
- schedule = "one-time"
- }
-
- nextRun := "scheduled"
- if job.State.NextRunAtMS != nil {
- nextTime := time.UnixMilli(*job.State.NextRunAtMS)
- nextRun = nextTime.Format("2006-01-02 15:04")
- }
-
- status := "enabled"
- if !job.Enabled {
- status = "disabled"
- }
-
- fmt.Printf(" %s (%s)\n", job.Name, job.ID)
- fmt.Printf(" Schedule: %s\n", schedule)
- fmt.Printf(" Status: %s\n", status)
- fmt.Printf(" Next run: %s\n", nextRun)
- }
-}
-
-func cronAddCmd(storePath string) {
- name := ""
- message := ""
- var everySec *int64
- cronExpr := ""
- deliver := false
- channel := ""
- to := ""
-
- args := os.Args[3:]
- for i := 0; i < len(args); i++ {
- switch args[i] {
- case "-n", "--name":
- if i+1 < len(args) {
- name = args[i+1]
- i++
- }
- case "-m", "--message":
- if i+1 < len(args) {
- message = args[i+1]
- i++
- }
- case "-e", "--every":
- if i+1 < len(args) {
- var sec int64
- fmt.Sscanf(args[i+1], "%d", &sec)
- everySec = &sec
- i++
- }
- case "-c", "--cron":
- if i+1 < len(args) {
- cronExpr = args[i+1]
- i++
- }
- case "-d", "--deliver":
- deliver = true
- case "--to":
- if i+1 < len(args) {
- to = args[i+1]
- i++
- }
- case "--channel":
- if i+1 < len(args) {
- channel = args[i+1]
- i++
- }
- }
- }
-
- if name == "" {
- fmt.Println("Error: --name is required")
- return
- }
-
- if message == "" {
- fmt.Println("Error: --message is required")
- return
- }
-
- if everySec == nil && cronExpr == "" {
- fmt.Println("Error: Either --every or --cron must be specified")
- return
- }
-
- var schedule cron.CronSchedule
- if everySec != nil {
- everyMS := *everySec * 1000
- schedule = cron.CronSchedule{
- Kind: "every",
- EveryMS: &everyMS,
- }
- } else {
- schedule = cron.CronSchedule{
- Kind: "cron",
- Expr: cronExpr,
- }
- }
-
- cs := cron.NewCronService(storePath, nil)
- job, err := cs.AddJob(name, schedule, message, deliver, channel, to)
- if err != nil {
- fmt.Printf("Error adding job: %v\n", err)
- return
- }
-
- fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID)
-}
-
-func cronRemoveCmd(storePath, jobID string) {
- cs := cron.NewCronService(storePath, nil)
- if cs.RemoveJob(jobID) {
- fmt.Printf("✓ Removed job %s\n", jobID)
- } else {
- fmt.Printf("✗ Job %s not found\n", jobID)
- }
-}
-
-func cronEnableCmd(storePath string, disable bool) {
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw cron enable/disable ")
- return
- }
-
- jobID := os.Args[3]
- cs := cron.NewCronService(storePath, nil)
- enabled := !disable
-
- job := cs.EnableJob(jobID, enabled)
- if job != nil {
- status := "enabled"
- if disable {
- status = "disabled"
- }
- fmt.Printf("✓ Job '%s' %s\n", job.Name, status)
- } else {
- fmt.Printf("✗ Job %s not found\n", jobID)
- }
-}
-
-func skillsHelp() {
- fmt.Println("\nSkills commands:")
- fmt.Println(" list List installed skills")
- fmt.Println(" install Install skill from GitHub")
- fmt.Println(" install-builtin Install all builtin skills to workspace")
- fmt.Println(" list-builtin List available builtin skills")
- fmt.Println(" remove Remove installed skill")
- fmt.Println(" search Search available skills")
- fmt.Println(" show Show skill details")
- fmt.Println()
- fmt.Println("Examples:")
- fmt.Println(" picoclaw skills list")
- fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
- fmt.Println(" picoclaw skills install-builtin")
- fmt.Println(" picoclaw skills list-builtin")
- fmt.Println(" picoclaw skills remove weather")
-}
-
-func skillsListCmd(loader *skills.SkillsLoader) {
- allSkills := loader.ListSkills()
-
- if len(allSkills) == 0 {
- fmt.Println("No skills installed.")
- return
- }
-
- fmt.Println("\nInstalled Skills:")
- fmt.Println("------------------")
- for _, skill := range allSkills {
- fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
- if skill.Description != "" {
- fmt.Printf(" %s\n", skill.Description)
- }
- }
-}
-
-func skillsInstallCmd(installer *skills.SkillInstaller) {
- if len(os.Args) < 4 {
- fmt.Println("Usage: picoclaw skills install ")
- fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
- return
- }
-
- repo := os.Args[3]
- fmt.Printf("Installing skill from %s...\n", repo)
-
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- if err := installer.InstallFromGitHub(ctx, repo); err != nil {
- fmt.Printf("✗ Failed to install skill: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo))
-}
-
-func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
- fmt.Printf("Removing skill '%s'...\n", skillName)
-
- if err := installer.Uninstall(skillName); err != nil {
- fmt.Printf("✗ Failed to remove skill: %v\n", err)
- os.Exit(1)
- }
-
- fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
-}
-
-func skillsInstallBuiltinCmd(workspace string) {
- builtinSkillsDir := "./picoclaw/skills"
- workspaceSkillsDir := filepath.Join(workspace, "skills")
-
- fmt.Printf("Copying builtin skills to workspace...\n")
-
- skillsToInstall := []string{
- "weather",
- "news",
- "stock",
- "calculator",
- }
-
- for _, skillName := range skillsToInstall {
- builtinPath := filepath.Join(builtinSkillsDir, skillName)
- workspacePath := filepath.Join(workspaceSkillsDir, skillName)
-
- if _, err := os.Stat(builtinPath); err != nil {
- fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
- continue
- }
-
- if err := os.MkdirAll(workspacePath, 0755); err != nil {
- fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
- continue
- }
-
- if err := copyDirectory(builtinPath, workspacePath); err != nil {
- fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
- }
- }
-
- fmt.Println("\n✓ All builtin skills installed!")
- fmt.Println("Now you can use them in your workspace.")
-}
-
-func skillsListBuiltinCmd() {
- cfg, err := loadConfig()
- if err != nil {
- fmt.Printf("Error loading config: %v\n", err)
- return
- }
- builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
-
- fmt.Println("\nAvailable Builtin Skills:")
- fmt.Println("-----------------------")
-
- entries, err := os.ReadDir(builtinSkillsDir)
- if err != nil {
- fmt.Printf("Error reading builtin skills: %v\n", err)
- return
- }
-
- if len(entries) == 0 {
- fmt.Println("No builtin skills available.")
- return
- }
-
- for _, entry := range entries {
- if entry.IsDir() {
- skillName := entry.Name()
- skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
-
- description := "No description"
- if _, err := os.Stat(skillFile); err == nil {
- data, err := os.ReadFile(skillFile)
- if err == nil {
- content := string(data)
- if idx := strings.Index(content, "\n"); idx > 0 {
- firstLine := content[:idx]
- if strings.Contains(firstLine, "description:") {
- descLine := strings.Index(content[idx:], "\n")
- if descLine > 0 {
- description = strings.TrimSpace(content[idx+descLine : idx+descLine])
- }
- }
- }
- }
- }
- status := "✓"
- fmt.Printf(" %s %s\n", status, entry.Name())
- if description != "" {
- fmt.Printf(" %s\n", description)
- }
- }
- }
-}
-
-func skillsSearchCmd(installer *skills.SkillInstaller) {
- fmt.Println("Searching for available skills...")
-
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
- defer cancel()
-
- availableSkills, err := installer.ListAvailableSkills(ctx)
- if err != nil {
- fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
- return
- }
-
- if len(availableSkills) == 0 {
- fmt.Println("No skills available.")
- return
- }
-
- fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
- fmt.Println("--------------------")
- for _, skill := range availableSkills {
- fmt.Printf(" 📦 %s\n", skill.Name)
- fmt.Printf(" %s\n", skill.Description)
- fmt.Printf(" Repo: %s\n", skill.Repository)
- if skill.Author != "" {
- fmt.Printf(" Author: %s\n", skill.Author)
- }
- if len(skill.Tags) > 0 {
- fmt.Printf(" Tags: %v\n", skill.Tags)
- }
- fmt.Println()
- }
-}
-
-func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
- content, ok := loader.LoadSkill(skillName)
- if !ok {
- fmt.Printf("✗ Skill '%s' not found\n", skillName)
- return
- }
-
- fmt.Printf("\n📦 Skill: %s\n", skillName)
- fmt.Println("----------------------")
- fmt.Println(content)
-}
diff --git a/config/config.example.json b/config/config.example.json
index aa75c8338..e8c6b3d3f 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -3,22 +3,67 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
- "model": "glm-4.7",
+ "model_name": "gpt4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20
}
},
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key",
+ "api_base": "https://api.anthropic.com/v1"
+ },
+ {
+ "model_name": "gemini",
+ "model": "antigravity/gemini-2.0-flash",
+ "auth_method": "oauth"
+ },
+ {
+ "model_name": "deepseek",
+ "model": "deepseek/deepseek-chat",
+ "api_key": "sk-your-deepseek-key"
+ },
+ {
+ "model_name": "loadbalanced-gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key1",
+ "api_base": "https://api1.example.com/v1"
+ },
+ {
+ "model_name": "loadbalanced-gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key2",
+ "api_base": "https://api2.example.com/v1"
+ }
+ ],
"channels": {
"telegram": {
"enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN",
"proxy": "",
- "allow_from": ["YOUR_USER_ID"]
+ "allow_from": [
+ "YOUR_USER_ID"
+ ]
},
"discord": {
"enabled": false,
"token": "YOUR_DISCORD_BOT_TOKEN",
+ "allow_from": [],
+ "mention_only": false
+ },
+ "qq": {
+ "enabled": false,
+ "app_id": "YOUR_QQ_APP_ID",
+ "app_secret": "YOUR_QQ_APP_SECRET",
"allow_from": []
},
"maixcam": {
@@ -68,16 +113,44 @@
"reconnect_interval": 5,
"group_trigger_prefix": [],
"allow_from": []
+ },
+ "wecom": {
+ "_comment": "WeCom Bot (智能机器人) - Easier setup, supports group chats",
+ "enabled": false,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ },
+ "wecom_app": {
+ "_comment": "WeCom App (自建应用) - More features, proactive messaging, private chat only. See docs/wecom-app-configuration.md",
+ "enabled": false,
+ "corp_id": "YOUR_CORP_ID",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_43_CHAR_ENCODING_AES_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
}
},
"providers": {
+ "_comment": "DEPRECATED: Use model_list instead. This will be removed in a future version",
"anthropic": {
"api_key": "",
"api_base": ""
},
"openai": {
"api_key": "",
- "api_base": ""
+ "api_base": "",
+ "web_search": true
},
"openrouter": {
"api_key": "sk-or-v1-xxx",
@@ -107,13 +180,61 @@
"moonshot": {
"api_key": "sk-xxx",
"api_base": ""
+ },
+ "qwen": {
+ "api_key": "sk-xxx",
+ "api_base": ""
+ },
+ "ollama": {
+ "api_key": "",
+ "api_base": "http://localhost:11434/v1"
+ },
+ "cerebras": {
+ "api_key": "",
+ "api_base": ""
+ },
+ "volcengine": {
+ "api_key": "",
+ "api_base": ""
+ },
+ "mistral": {
+ "api_key": "",
+ "api_base": "https://api.mistral.ai/v1"
}
},
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ },
+ "perplexity": {
+ "enabled": false,
+ "api_key": "pplx-xxx",
+ "max_results": 5
+ }
+ },
+ "cron": {
+ "exec_timeout_minutes": 5
+ },
+ "exec": {
+ "enable_deny_patterns": false,
+ "custom_deny_patterns": []
+ },
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "enabled": true,
+ "base_url": "https://clawhub.ai",
+ "search_path": "/api/v1/search",
+ "skills_path": "/api/v1/skills",
+ "download_path": "/api/v1/download"
+ }
}
}
},
@@ -126,7 +247,7 @@
"monitor_usb": true
},
"gateway": {
- "host": "0.0.0.0",
+ "host": "127.0.0.1",
"port": 18790
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 48769627c..c268b01cd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,9 +10,12 @@ services:
container_name: picoclaw-agent
profiles:
- agent
+ # Uncomment to access host network; leave commented unless needed.
+ #extra_hosts:
+ # - "host.docker.internal:host-gateway"
volumes:
- - ./config/config.json:/root/.picoclaw/config.json:ro
- - picoclaw-workspace:/root/.picoclaw/workspace
+ - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
+ - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
entrypoint: ["picoclaw", "agent"]
stdin_open: true
tty: true
@@ -29,11 +32,14 @@ services:
restart: unless-stopped
profiles:
- gateway
+ # Uncomment to access host network; leave commented unless needed.
+ #extra_hosts:
+ # - "host.docker.internal:host-gateway"
volumes:
# Configuration file
- - ./config/config.json:/root/.picoclaw/config.json:ro
+ - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro
# Persistent workspace (sessions, memory, logs)
- - picoclaw-workspace:/root/.picoclaw/workspace
+ - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
command: ["gateway"]
volumes:
diff --git a/docs/ANTIGRAVITY_AUTH.md b/docs/ANTIGRAVITY_AUTH.md
new file mode 100644
index 000000000..89261d899
--- /dev/null
+++ b/docs/ANTIGRAVITY_AUTH.md
@@ -0,0 +1,807 @@
+# Antigravity Authentication & Integration Guide
+
+## Overview
+
+**Antigravity** (Google Cloud Code Assist) is a Google-backed AI model provider that offers access to models like Claude Opus 4.6 and Gemini through Google's Cloud infrastructure. This document provides a complete guide on how authentication works, how to fetch models, and how to implement a new provider in PicoClaw.
+
+---
+
+## Table of Contents
+
+1. [Authentication Flow](#authentication-flow)
+2. [OAuth Implementation Details](#oauth-implementation-details)
+3. [Token Management](#token-management)
+4. [Models List Fetching](#models-list-fetching)
+5. [Usage Tracking](#usage-tracking)
+6. [Provider Plugin Structure](#provider-plugin-structure)
+7. [Integration Requirements](#integration-requirements)
+8. [API Endpoints](#api-endpoints)
+9. [Configuration](#configuration)
+10. [Creating a New Provider in PicoClaw](#creating-a-new-provider-in-picoclaw)
+
+---
+
+## Authentication Flow
+
+### 1. OAuth 2.0 with PKCE
+
+Antigravity uses **OAuth 2.0 with PKCE (Proof Key for Code Exchange)** for secure authentication:
+
+```
+┌─────────────┐ ┌─────────────────┐
+│ Client │ ───(1) Generate PKCE Pair────────> │ │
+│ │ ───(2) Open Auth URL─────────────> │ Google OAuth │
+│ │ │ Server │
+│ │ <──(3) Redirect with Code───────── │ │
+│ │ └─────────────────┘
+│ │ ───(4) Exchange Code for Tokens──> │ Token URL │
+│ │ │ │
+│ │ <──(5) Access + Refresh Tokens──── │ │
+└─────────────┘ └─────────────────┘
+```
+
+### 2. Detailed Steps
+
+#### Step 1: Generate PKCE Parameters
+```typescript
+function generatePkce(): { verifier: string; challenge: string } {
+ const verifier = randomBytes(32).toString("hex");
+ const challenge = createHash("sha256").update(verifier).digest("base64url");
+ return { verifier, challenge };
+}
+```
+
+#### Step 2: Build Authorization URL
+```typescript
+const AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth";
+const REDIRECT_URI = "http://localhost:51121/oauth-callback";
+
+function buildAuthUrl(params: { challenge: string; state: string }): string {
+ const url = new URL(AUTH_URL);
+ url.searchParams.set("client_id", CLIENT_ID);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("redirect_uri", REDIRECT_URI);
+ url.searchParams.set("scope", SCOPES.join(" "));
+ url.searchParams.set("code_challenge", params.challenge);
+ url.searchParams.set("code_challenge_method", "S256");
+ url.searchParams.set("state", params.state);
+ url.searchParams.set("access_type", "offline");
+ url.searchParams.set("prompt", "consent");
+ return url.toString();
+}
+```
+
+**Required Scopes:**
+```typescript
+const SCOPES = [
+ "https://www.googleapis.com/auth/cloud-platform",
+ "https://www.googleapis.com/auth/userinfo.email",
+ "https://www.googleapis.com/auth/userinfo.profile",
+ "https://www.googleapis.com/auth/cclog",
+ "https://www.googleapis.com/auth/experimentsandconfigs",
+];
+```
+
+#### Step 3: Handle OAuth Callback
+
+**Automatic Mode (Local Development):**
+- Start a local HTTP server on port 51121
+- Wait for the redirect from Google
+- Extract the authorization code from the query parameters
+
+**Manual Mode (Remote/Headless):**
+- Display the authorization URL to the user
+- User completes authentication in their browser
+- User pastes the full redirect URL back into the terminal
+- Parse the code from the pasted URL
+
+#### Step 4: Exchange Code for Tokens
+```typescript
+const TOKEN_URL = "https://oauth2.googleapis.com/token";
+
+async function exchangeCode(params: {
+ code: string;
+ verifier: string;
+}): Promise<{ access: string; refresh: string; expires: number }> {
+ const response = await fetch(TOKEN_URL, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ client_id: CLIENT_ID,
+ client_secret: CLIENT_SECRET,
+ code: params.code,
+ grant_type: "authorization_code",
+ redirect_uri: REDIRECT_URI,
+ code_verifier: params.verifier,
+ }),
+ });
+
+ const data = await response.json();
+
+ return {
+ access: data.access_token,
+ refresh: data.refresh_token,
+ expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000, // 5 min buffer
+ };
+}
+```
+
+#### Step 5: Fetch Additional User Data
+
+**User Email:**
+```typescript
+async function fetchUserEmail(accessToken: string): Promise {
+ const response = await fetch(
+ "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
+ { headers: { Authorization: `Bearer ${accessToken}` } }
+ );
+ const data = await response.json();
+ return data.email;
+}
+```
+
+**Project ID (Required for API calls):**
+```typescript
+async function fetchProjectId(accessToken: string): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "google-api-nodejs-client/9.15.1",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ "Client-Metadata": JSON.stringify({
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ }),
+ };
+
+ const response = await fetch(
+ "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist",
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ metadata: {
+ ideType: "IDE_UNSPECIFIED",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ const data = await response.json();
+ return data.cloudaicompanionProject || "rising-fact-p41fc"; // Default fallback
+}
+```
+
+---
+
+## OAuth Implementation Details
+
+### Client Credentials
+
+**Important:** These are base64-encoded in the source code for sync with pi-ai:
+
+```typescript
+const decode = (s: string) => Buffer.from(s, "base64").toString();
+
+const CLIENT_ID = decode(
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ=="
+);
+const CLIENT_SECRET = decode("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=");
+```
+
+### OAuth Flow Modes
+
+1. **Automatic Flow** (Local machines with browser):
+ - Opens browser automatically
+ - Local callback server captures redirect
+ - No user interaction required after initial auth
+
+2. **Manual Flow** (Remote/headless/WSL2):
+ - URL displayed for manual copy-paste
+ - User completes auth in external browser
+ - User pastes full redirect URL back
+
+```typescript
+function shouldUseManualOAuthFlow(isRemote: boolean): boolean {
+ return isRemote || isWSL2Sync();
+}
+```
+
+---
+
+## Token Management
+
+### Auth Profile Structure
+
+```typescript
+type OAuthCredential = {
+ type: "oauth";
+ provider: "google-antigravity";
+ access: string; // Access token
+ refresh: string; // Refresh token
+ expires: number; // Expiration timestamp (ms since epoch)
+ email?: string; // User email
+ projectId?: string; // Google Cloud project ID
+};
+```
+
+### Token Refresh
+
+The credential includes a refresh token that can be used to obtain new access tokens when the current one expires. The expiration is set with a 5-minute buffer to prevent race conditions.
+
+---
+
+## Models List Fetching
+
+### Fetch Available Models
+
+```typescript
+const BASE_URL = "https://cloudcode-pa.googleapis.com";
+
+async function fetchAvailableModels(
+ accessToken: string,
+ projectId: string
+): Promise {
+ const headers = {
+ Authorization: `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity",
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+ };
+
+ const response = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ const data = await response.json();
+
+ // Returns models with quota information
+ return Object.entries(data.models).map(([modelId, modelInfo]) => ({
+ id: modelId,
+ displayName: modelInfo.displayName,
+ quotaInfo: {
+ remainingFraction: modelInfo.quotaInfo?.remainingFraction,
+ resetTime: modelInfo.quotaInfo?.resetTime,
+ isExhausted: modelInfo.quotaInfo?.isExhausted,
+ },
+ }));
+}
+```
+
+### Response Format
+
+```typescript
+type FetchAvailableModelsResponse = {
+ models?: Record;
+};
+```
+
+---
+
+## Usage Tracking
+
+### Fetch Usage Data
+
+```typescript
+export async function fetchAntigravityUsage(
+ token: string,
+ timeoutMs: number
+): Promise {
+ // 1. Fetch credits and plan info
+ const loadCodeAssistRes = await fetch(
+ `${BASE_URL}/v1internal:loadCodeAssist`,
+ {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ metadata: {
+ ideType: "ANTIGRAVITY",
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+ },
+ }),
+ }
+ );
+
+ // Extract credits info
+ const { availablePromptCredits, planInfo, currentTier } = data;
+
+ // 2. Fetch model quotas
+ const modelsRes = await fetch(
+ `${BASE_URL}/v1internal:fetchAvailableModels`,
+ {
+ method: "POST",
+ headers: { Authorization: `Bearer ${token}` },
+ body: JSON.stringify({ project: projectId }),
+ }
+ );
+
+ // Build usage windows
+ return {
+ provider: "google-antigravity",
+ displayName: "Google Antigravity",
+ windows: [
+ { label: "Credits", usedPercent: calculateUsedPercent(available, monthly) },
+ // Individual model quotas...
+ ],
+ plan: currentTier?.name || planType,
+ };
+}
+```
+
+### Usage Response Structure
+
+```typescript
+type ProviderUsageSnapshot = {
+ provider: "google-antigravity";
+ displayName: string;
+ windows: UsageWindow[];
+ plan?: string;
+ error?: string;
+};
+
+type UsageWindow = {
+ label: string; // "Credits" or model ID
+ usedPercent: number; // 0-100
+ resetAt?: number; // Timestamp when quota resets
+};
+```
+
+---
+
+## Provider Plugin Structure
+
+### Plugin Definition
+
+```typescript
+const antigravityPlugin = {
+ id: "google-antigravity-auth",
+ name: "Google Antigravity Auth",
+ description: "OAuth flow for Google Antigravity (Cloud Code Assist)",
+ configSchema: emptyPluginConfigSchema(),
+
+ register(api: PicoClawPluginApi) {
+ api.registerProvider({
+ id: "google-antigravity",
+ label: "Google Antigravity",
+ docsPath: "/providers/models",
+ aliases: ["antigravity"],
+
+ auth: [
+ {
+ id: "oauth",
+ label: "Google OAuth",
+ hint: "PKCE + localhost callback",
+ kind: "oauth",
+ run: async (ctx: ProviderAuthContext) => {
+ // OAuth implementation here
+ },
+ },
+ ],
+ });
+ },
+};
+```
+
+### ProviderAuthContext
+
+```typescript
+type ProviderAuthContext = {
+ config: PicoClawConfig;
+ agentDir?: string;
+ workspaceDir?: string;
+ prompter: WizardPrompter; // UI prompts/notifications
+ runtime: RuntimeEnv; // Logging, etc.
+ isRemote: boolean; // Whether running remotely
+ openUrl: (url: string) => Promise; // Browser opener
+ oauth: {
+ createVpsAwareHandlers: Function;
+ };
+};
+```
+
+### ProviderAuthResult
+
+```typescript
+type ProviderAuthResult = {
+ profiles: Array<{
+ profileId: string;
+ credential: AuthProfileCredential;
+ }>;
+ configPatch?: Partial;
+ defaultModel?: string;
+ notes?: string[];
+};
+```
+
+---
+
+## Integration Requirements
+
+### 1. Required Environment/Dependencies
+
+- Go ≥ 1.21
+- PicoClaw codebase (`pkg/providers/` and `pkg/auth/`)
+- `crypto` and `net/http` standard library packages
+
+### 2. Required Headers for API Calls
+
+```typescript
+const REQUIRED_HEADERS = {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/json",
+ "User-Agent": "antigravity", // or "google-api-nodejs-client/9.15.1"
+ "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
+};
+
+// For loadCodeAssist calls, also include:
+const CLIENT_METADATA = {
+ ideType: "ANTIGRAVITY", // or "IDE_UNSPECIFIED"
+ platform: "PLATFORM_UNSPECIFIED",
+ pluginType: "GEMINI",
+};
+```
+
+### 3. Model Schema Sanitization
+
+Antigravity uses Gemini-compatible models, so tool schemas must be sanitized:
+
+```typescript
+const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
+ "patternProperties",
+ "additionalProperties",
+ "$schema",
+ "$id",
+ "$ref",
+ "$defs",
+ "definitions",
+ "examples",
+ "minLength",
+ "maxLength",
+ "minimum",
+ "maximum",
+ "multipleOf",
+ "pattern",
+ "format",
+ "minItems",
+ "maxItems",
+ "uniqueItems",
+ "minProperties",
+ "maxProperties",
+]);
+
+// Clean schema before sending
+function cleanToolSchemaForGemini(schema: Record): unknown {
+ // Remove unsupported keywords
+ // Ensure top-level has type: "object"
+ // Flatten anyOf/oneOf unions
+}
+```
+
+### 4. Thinking Block Handling (Claude Models)
+
+For Antigravity Claude models, thinking blocks require special handling:
+
+```typescript
+const ANTIGRAVITY_SIGNATURE_RE = /^[A-Za-z0-9+/]+={0,2}$/;
+
+export function sanitizeAntigravityThinkingBlocks(
+ messages: AgentMessage[]
+): AgentMessage[] {
+ // Validate thinking signatures
+ // Normalize signature fields
+ // Discard unsigned thinking blocks
+}
+```
+
+---
+
+## API Endpoints
+
+### Authentication Endpoints
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `https://accounts.google.com/o/oauth2/v2/auth` | GET | OAuth authorization |
+| `https://oauth2.googleapis.com/token` | POST | Token exchange |
+| `https://www.googleapis.com/oauth2/v1/userinfo` | GET | User info (email) |
+
+### Cloud Code Assist Endpoints
+
+| Endpoint | Method | Purpose |
+|----------|--------|---------|
+| `https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist` | POST | Load project info, credits, plan |
+| `https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels` | POST | List available models with quotas |
+| `https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse` | POST | Chat streaming endpoint |
+
+**API Request Format (Chat):**
+The `v1internal:streamGenerateContent` endpoint expects an envelope wrapping the standard Gemini request:
+
+```json
+{
+ "project": "your-project-id",
+ "model": "model-id",
+ "request": {
+ "contents": [...],
+ "systemInstruction": {...},
+ "generationConfig": {...},
+ "tools": [...]
+ },
+ "requestType": "agent",
+ "userAgent": "antigravity",
+ "requestId": "agent-timestamp-random"
+}
+```
+
+**API Response Format (SSE):**
+Each SSE message (`data: {...}`) is wrapped in a `response` field:
+
+```json
+{
+ "response": {
+ "candidates": [...],
+ "usageMetadata": {...},
+ "modelVersion": "...",
+ "responseId": "..."
+ },
+ "traceId": "...",
+ "metadata": {}
+}
+```
+
+---
+
+## Configuration
+
+### config.json Configuration
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gemini-flash",
+ "model": "antigravity/gemini-3-flash",
+ "auth_method": "oauth"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gemini-flash"
+ }
+ }
+}
+```
+
+### Auth Profile Storage
+
+Auth profiles are stored in `~/.picoclaw/auth.json`:
+
+```json
+{
+ "credentials": {
+ "google-antigravity": {
+ "access_token": "ya29...",
+ "refresh_token": "1//...",
+ "expires_at": "2026-01-01T00:00:00Z",
+ "provider": "google-antigravity",
+ "auth_method": "oauth",
+ "email": "user@example.com",
+ "project_id": "my-project-id"
+ }
+ }
+}
+```
+
+---
+
+## Creating a New Provider in PicoClaw
+
+PicoClaw providers are implemented as Go packages under `pkg/providers/`. To add a new provider:
+
+### Step-by-Step Implementation
+
+#### 1. Create Provider File
+
+Create a new Go file in `pkg/providers/`:
+
+```
+pkg/providers/
+└── your_provider.go
+```
+
+#### 2. Implement the Provider Interface
+
+Your provider must implement the `Provider` interface defined in `pkg/providers/types.go`:
+
+```go
+package providers
+
+type YourProvider struct {
+ apiKey string
+ apiBase string
+}
+
+func NewYourProvider(apiKey, apiBase, proxy string) *YourProvider {
+ if apiBase == "" {
+ apiBase = "https://api.your-provider.com/v1"
+ }
+ return &YourProvider{apiKey: apiKey, apiBase: apiBase}
+}
+
+func (p *YourProvider) Chat(ctx context.Context, messages []Message, tools []Tool, cb StreamCallback) error {
+ // Implement chat completion with streaming
+}
+```
+
+#### 3. Register in the Factory
+
+Add your provider to the protocol switch in `pkg/providers/factory.go`:
+
+```go
+case "your-provider":
+ return NewYourProvider(sel.apiKey, sel.apiBase, sel.proxy), nil
+```
+
+#### 4. Add Default Config (Optional)
+
+Add a default entry in `pkg/config/defaults.go`:
+
+```go
+{
+ ModelName: "your-model",
+ Model: "your-provider/model-name",
+ APIKey: "",
+},
+```
+
+#### 5. Add Auth Support (Optional)
+
+If your provider requires OAuth or special authentication, add a case to `cmd/picoclaw/cmd_auth.go`:
+
+```go
+case "your-provider":
+ authLoginYourProvider()
+```
+
+#### 6. Configure via `config.json`
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "your-model",
+ "model": "your-provider/model-name",
+ "api_key": "your-api-key",
+ "api_base": "https://api.your-provider.com/v1"
+ }
+ ]
+}
+```
+
+---
+
+## Testing Your Implementation
+
+### CLI Commands
+
+```bash
+# Authenticate with a provider
+picoclaw auth login --provider your-provider
+
+# List models (for Antigravity)
+picoclaw auth models
+
+# Start the gateway
+picoclaw gateway
+
+# Run an agent with a specific model
+picoclaw agent -m "Hello" --model your-model
+```
+
+### Environment Variables for Testing
+
+```bash
+# Override default model
+export PICOCLAW_AGENTS_DEFAULTS_MODEL=your-model
+
+# Override provider settings
+export PICOCLAW_MODEL_LIST='[{"model_name":"your-model","model":"your-provider/model-name","api_key":"..."}]'
+```
+
+---
+
+## References
+
+- **Source Files:**
+ - `pkg/providers/antigravity_provider.go` - Antigravity provider implementation
+ - `pkg/auth/oauth.go` - OAuth flow implementation
+ - `pkg/auth/store.go` - Auth credential storage (`~/.picoclaw/auth.json`)
+ - `pkg/providers/factory.go` - Provider factory and protocol routing
+ - `pkg/providers/types.go` - Provider interface definitions
+ - `cmd/picoclaw/cmd_auth.go` - Auth CLI commands
+
+- **Documentation:**
+ - `docs/ANTIGRAVITY_USAGE.md` - Antigravity usage guide
+ - `docs/migration/model-list-migration.md` - Migration guide
+
+---
+
+## Notes
+
+1. **Google Cloud Project:** Antigravity requires Gemini for Google Cloud to be enabled on your Google Cloud project
+2. **Quotas:** Uses Google Cloud project quotas (not separate billing)
+3. **Model Access:** Available models depend on your Google Cloud project configuration
+4. **Thinking Blocks:** Claude models via Antigravity require special handling of thinking blocks with signatures
+5. **Schema Sanitization:** Tool schemas must be sanitized to remove unsupported JSON Schema keywords
+
+---
+
+---
+
+## Common Error Handling
+
+### 1. Rate Limiting (HTTP 429)
+
+Antigravity returns a 429 error when project/model quotas are exhausted. The error response often contains a `quotaResetDelay` in the `details` field.
+
+**Example 429 Error:**
+```json
+{
+ "error": {
+ "code": 429,
+ "message": "You have exhausted your capacity on this model. Your quota will reset after 4h30m28s.",
+ "status": "RESOURCE_EXHAUSTED",
+ "details": [
+ {
+ "@type": "type.googleapis.com/google.rpc.ErrorInfo",
+ "metadata": {
+ "quotaResetDelay": "4h30m28.060903746s"
+ }
+ }
+ ]
+ }
+}
+```
+
+### 2. Empty Responses (Restricted Models)
+
+Some models might show up in the available models list but return an empty response (200 OK but empty SSE stream). This usually happens for preview or restricted models that the current project doesn't have permission to use.
+
+**Treatment:** Treat empty responses as errors informing the user that the model might be restricted or invalid for their project.
+
+---
+
+## Troubleshooting
+
+### "Token expired"
+- Refresh OAuth tokens: `picoclaw auth login --provider antigravity`
+
+### "Gemini for Google Cloud is not enabled"
+- Enable the API in your Google Cloud Console
+
+### "Project not found"
+- Ensure your Google Cloud project has the necessary APIs enabled
+- Check that the project ID is correctly fetched during authentication
+
+### Models not appearing in list
+- Verify OAuth authentication completed successfully
+- Check auth profile storage: `~/.picoclaw/auth.json`
+- Re-run `picoclaw auth login --provider antigravity`
diff --git a/docs/ANTIGRAVITY_USAGE.md b/docs/ANTIGRAVITY_USAGE.md
new file mode 100644
index 000000000..e8194b6bc
--- /dev/null
+++ b/docs/ANTIGRAVITY_USAGE.md
@@ -0,0 +1,70 @@
+# Using Antigravity Provider in PicoClaw
+
+This guide explains how to set up and use the **Antigravity** (Google Cloud Code Assist) provider in PicoClaw.
+
+## Prerequisites
+
+1. A Google account.
+2. Google Cloud Code Assist enabled (usually available via the "Gemini for Google Cloud" onboarding).
+
+## 1. Authentication
+
+To authenticate with Antigravity, run the following command:
+
+```bash
+picoclaw auth login --provider antigravity
+```
+
+### Manual Authentication (Headless/VPS)
+If you are running on a server (Coolify/Docker) and cannot reach `localhost`, follow these steps:
+1. Run the command above.
+2. Copy the URL provided and open it in your local browser.
+3. Complete the login.
+4. Your browser will redirect to a `localhost:51121` URL (which will fail to load).
+5. **Copy that final URL** from your browser's address bar.
+6. **Paste it back into the terminal** where PicoClaw is waiting.
+
+PicoClaw will extract the authorization code and complete the process automatically.
+
+## 2. Managing Models
+
+### List Available Models
+To see which models your project has access to and check their quotas:
+
+```bash
+picoclaw auth models
+```
+
+### Switch Models
+You can change the default model in `~/.picoclaw/config.json` or override it via the CLI:
+
+```bash
+# Override for a single command
+picoclaw agent -m "Hello" --model claude-opus-4-6-thinking
+```
+
+## 3. Real-world Usage (Coolify/Docker)
+
+If you are deploying via Coolify or Docker, follow these steps to test:
+
+1. **Environment Variables**:
+ * `PICOCLAW_AGENTS_DEFAULTS_MODEL=gemini-flash`
+2. **Authentication persistence**:
+ If you've logged in locally, you can copy your credentials to the server:
+ ```bash
+ scp ~/.picoclaw/auth.json user@your-server:~/.picoclaw/
+ ```
+ *Alternatively*, run the `auth login` command once on the server if you have terminal access.
+
+## 4. Troubleshooting
+
+* **Empty Response**: If a model returns an empty reply, it may be restricted for your project. Try `gemini-3-flash` or `claude-opus-4-6-thinking`.
+* **429 Rate Limit**: Antigravity has strict quotas. PicoClaw will display the "reset time" in the error message if you hit a limit.
+* **404 Not Found**: Ensure you are using a model ID from the `picoclaw auth models` list. Use the short ID (e.g., `gemini-3-flash`) not the full path.
+
+## 5. Summary of Working Models
+
+Based on testing, the following models are most reliable:
+* `gemini-3-flash` (Fast, highly available)
+* `gemini-2.5-flash-lite` (Lightweight)
+* `claude-opus-4-6-thinking` (Powerful, includes reasoning)
diff --git a/docs/channels/dingtalk/README.zh.md b/docs/channels/dingtalk/README.zh.md
new file mode 100644
index 000000000..1e445d0b0
--- /dev/null
+++ b/docs/channels/dingtalk/README.zh.md
@@ -0,0 +1,33 @@
+# 钉钉
+
+钉钉是阿里巴巴的企业通讯平台,在中国职场中广受欢迎。它采用流式 SDK 来维持持久连接。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "dingtalk": {
+ "enabled": true,
+ "client_id": "YOUR_CLIENT_ID",
+ "client_secret": "YOUR_CLIENT_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用钉钉频道 |
+| client_id | string | 是 | 钉钉应用的 Client ID |
+| client_secret | string | 是 | 钉钉应用的 Client Secret |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [钉钉开放平台](https://open.dingtalk.com/)
+2. 创建一个企业内部应用
+3. 从应用设置中获取 Client ID 和 Client Secret
+4. 配置OAuth和事件订阅(如需要)
+5. 将 Client ID 和 Client Secret 填入配置文件中
diff --git a/docs/channels/discord/README.zh.md b/docs/channels/discord/README.zh.md
new file mode 100644
index 000000000..5b597eced
--- /dev/null
+++ b/docs/channels/discord/README.zh.md
@@ -0,0 +1,35 @@
+# Discord
+
+Discord 是一个专为社区设计的免费语音、视频和文本聊天应用。PicoClaw 通过 Discord Bot API 连接到 Discord 服务器,支持接收和发送消息。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "discord": {
+ "enabled": true,
+ "token": "YOUR_BOT_TOKEN",
+ "allow_from": ["YOUR_USER_ID"],
+ "mention_only": false
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 Discord 频道 |
+| token | string | 是 | Discord 机器人 Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| mention_only | bool | 否 | 是否仅响应提及机器人的消息 |
+
+## 设置流程
+
+1. 前往 [Discord 开发者门户](https://discord.com/developers/applications) 创建一个新的应用
+2. 启用 Intents:
+ - Message Content Intent
+ - Server Members Intent
+3. 获取 Bot Token
+4. 将 Bot Token 填入配置文件中
+5. 邀请机器人加入服务器并授予必要权限(例如发送消息、读取消息历史等)
diff --git a/docs/channels/feishu/README.zh.md b/docs/channels/feishu/README.zh.md
new file mode 100644
index 000000000..310827723
--- /dev/null
+++ b/docs/channels/feishu/README.zh.md
@@ -0,0 +1,37 @@
+# 飞书
+
+飞书(国际版名称:Lark)是字节跳动旗下的企业协作平台。它通过事件驱动的 Webhook 同时支持中国和全球市场。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "feishu": {
+ "enabled": true,
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用飞书频道 |
+| app_id | string | 是 | 飞书应用的 App ID(以cli\_开头) |
+| app_secret | string | 是 | 飞书应用的 App Secret |
+| encrypt_key | string | 否 | 事件回调加密密钥 |
+| verification_token | string | 否 | 用于Webhook事件验证的Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [飞书开放平台](https://open.feishu.cn/)创建应用程序
+2. 获取 App ID 和 App Secret
+3. 配置事件订阅和Webhook URL
+4. 设置加密(可选,生产环境建议启用)
+5. 将 App ID、App Secret、Encrypt Key 和 Verification Token(如果启用加密) 填入配置文件中
diff --git a/docs/channels/line/README.zh.md b/docs/channels/line/README.zh.md
new file mode 100644
index 000000000..fd3aa80da
--- /dev/null
+++ b/docs/channels/line/README.zh.md
@@ -0,0 +1,41 @@
+# Line
+
+PicoClaw 通过 LINE Messaging API 配合 Webhook 回调功能实现对 LINE 的支持。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "line": {
+ "enabled": true,
+ "channel_secret": "YOUR_CHANNEL_SECRET",
+ "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18791,
+ "webhook_path": "/webhook/line",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| -------------------- | ------ | ---- | ------------------------------------------ |
+| enabled | bool | 是 | 是否启用 LINE Channel |
+| channel_secret | string | 是 | LINE Messaging API 的 Channel Secret |
+| channel_access_token | string | 是 | LINE Messaging API 的 Channel Access Token |
+| webhook_host | string | 是 | Webhook 监听的主机地址 (通常为 0.0.0.0) |
+| webhook_port | int | 是 | Webhook 监听的端口 (默认为 18791) |
+| webhook_path | string | 是 | Webhook 的路径 (默认为 /webhook/line) |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [LINE Developers Console](https://developers.line.biz/console/) 创建一个服务提供商和一个 Messaging API Channel
+2. 获取 Channel Secret 和 Channel Access Token
+3. 配置Webhook:
+ - Line要求Webhook必须使用HTTPS协议,因此需要部署一个支持HTTPS的服务器,或者使用反向代理工具如ngrok将本地服务器暴露到公网
+ - 将 Webhook URL 设置为 `https://your-domain.com/webhook/line`
+ - 启用 Webhook 并验证 URL
+4. 将 Channel Secret 和 Channel Access Token 填入配置文件中
diff --git a/docs/channels/maixcam/README.zh.md b/docs/channels/maixcam/README.zh.md
new file mode 100644
index 000000000..8d53d4bef
--- /dev/null
+++ b/docs/channels/maixcam/README.zh.md
@@ -0,0 +1,31 @@
+# MaixCam
+
+MaixCam 是专用于连接矽速科技 MaixCAM 与 MaixCAM2 AI 摄像设备的通道。它采用 TCP 套接字实现双向通信,支持边缘 AI 部署场景。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "maixcam": {
+ "enabled": true,
+ "server_address": "0.0.0.0:8899",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| -------------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 MaixCam 频道 |
+| server_address | string | 是 | TCP 服务器监听地址和端口 |
+| allow_from | array | 否 | 设备ID白名单,空表示允许所有设备 |
+
+## 使用场景
+
+MaixCam 通道使 PicoClaw 能够作为边缘设备的 AI 后端运行:
+
+- **智能监控** :MaixCAM 发送图像帧,PicoClaw 通过视觉模型进行分析
+- **物联网控制** :设备发送传感器数据,PicoClaw 协调响应
+- **离线AI** :在本地网络部署 PicoClaw 实现低延迟推理
diff --git a/docs/channels/onebot/README.zh.md b/docs/channels/onebot/README.zh.md
new file mode 100644
index 000000000..6195f1c98
--- /dev/null
+++ b/docs/channels/onebot/README.zh.md
@@ -0,0 +1,31 @@
+# OneBot
+
+OneBot 是一个面向 QQ 机器人的开放协议标准,为多种 QQ 机器人实现(例如 go-cqhttp、Mirai)提供了统一的接口。它使用 WebSocket 进行通信。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "onebot": {
+ "enabled": true,
+ "ws_url": "ws://localhost:8080",
+ "access_token": "",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ------------ | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 OneBot 频道 |
+| ws_url | string | 是 | OneBot 服务器的 WebSocket URL |
+| access_token | string | 否 | 连接 OneBot 服务器的访问令牌 |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 部署一个 OneBot 兼容的实现(例如napcat)
+2. 配置 OneBot 实现以启用 WebSocket 服务并设置访问令牌(如果需要)
+3. 将 WebSocket URL 和访问令牌填入配置文件中
diff --git a/docs/channels/qq/README.zh.md b/docs/channels/qq/README.zh.md
new file mode 100644
index 000000000..bd774960f
--- /dev/null
+++ b/docs/channels/qq/README.zh.md
@@ -0,0 +1,32 @@
+# QQ
+
+PicoClaw 通过 QQ 开放平台的官方机器人 API 提供对 QQ 的支持。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "qq": {
+ "enabled": true,
+ "app_id": "YOUR_APP_ID",
+ "app_secret": "YOUR_APP_SECRET",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | -------------------------------- |
+| enabled | bool | 是 | 是否启用 QQ Channel |
+| app_id | string | 是 | QQ 机器人应用的 App ID |
+| app_secret | string | 是 | QQ 机器人应用的 App Secret |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [QQ 开放平台](https://q.qq.com/) 创建一个机器人
+2. 通过仪表盘获取 App ID 和 App Secret
+3. 开启机器人沙箱模式, 将用户和群添加到沙箱中
+4. 将 App ID 和 App Secret 填入配置文件中
diff --git a/docs/channels/slack/README.zh.md b/docs/channels/slack/README.zh.md
new file mode 100644
index 000000000..58ebcb566
--- /dev/null
+++ b/docs/channels/slack/README.zh.md
@@ -0,0 +1,33 @@
+# Slack
+
+Slack 是全球领先的企业级即时通讯平台。PicoClaw 采用 Slack 的 Socket Mode 实现实时双向通信,无需配置公开的 Webhook 端点。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "slack": {
+ "enabled": true,
+ "bot_token": "xoxb-...",
+ "app_token": "xapp-...",
+ "allow_from": []
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | -------------------------------------------------------- |
+| enabled | bool | 是 | 是否启用 Slack 频道 |
+| bot_token | string | 是 | Slack 机器人的 Bot User OAuth Token (以 xoxb- 开头) |
+| app_token | string | 是 | Slack 应用的 Socket Mode App Level Token (以 xapp- 开头) |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+
+## 设置流程
+
+1. 前往 [Slack API](https://api.slack.com/) 创建一个新的 Slack 应用
+2. 启用 Socket Mode 并获取 App Level Token
+3. 添加 Bot Token Scopes(例如`chat:write`、`im:history`等)
+4. 安装应用到工作区并获取 Bot User OAuth Token
+5. 将 Bot Token 和 App Token 填入配置文件中
diff --git a/docs/channels/telegram/README.zh.md b/docs/channels/telegram/README.zh.md
new file mode 100644
index 000000000..d453c68fa
--- /dev/null
+++ b/docs/channels/telegram/README.zh.md
@@ -0,0 +1,33 @@
+# Telegram
+
+Telegram Channel 通过 Telegram 机器人 API 使用长轮询实现基于机器人的通信。它支持文本消息、媒体附件(照片、语音、音频、文档)、通过 Groq Whisper 进行语音转录以及内置命令处理器。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz",
+ "allow_from": ["123456789"],
+ "proxy": ""
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------- | ------ | ---- | --------------------------------------------------------- |
+| enabled | bool | 是 | 是否启用 Telegram 频道 |
+| token | string | 是 | Telegram 机器人 API Token |
+| allow_from | array | 否 | 用户ID白名单,空表示允许所有用户 |
+| proxy | string | 否 | 连接 Telegram API 的代理 URL (例如 http://127.0.0.1:7890) |
+
+## 设置流程
+
+1. 在 Telegram 中搜索 `@BotFather`
+2. 发送 `/newbot` 命令并按照提示创建新机器人
+3. 获取 HTTP API Token
+4. 将 Token 填入配置文件中
+5. (可选) 配置 `allow_from` 以限制允许互动的用户 ID (可通过 `@userinfobot` 获取 ID)
diff --git a/docs/channels/wecom/wecom_app/README.zh.md b/docs/channels/wecom/wecom_app/README.zh.md
new file mode 100644
index 000000000..1e6a0e2b3
--- /dev/null
+++ b/docs/channels/wecom/wecom_app/README.zh.md
@@ -0,0 +1,47 @@
+# 企业微信自建应用
+
+企业微信自建应用是指企业在企业微信中创建的应用,主要用于企业内部使用。通过企业微信自建应用,企业可以实现与员工的高效沟通和协作,提高工作效率。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx",
+ "corp_secret": "YOUR_CORP_SECRET",
+ "agent_id": 1000002,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------------- | ------ | ---- | ---------------------------------------- |
+| corp_id | string | 是 | 企业 ID |
+| corp_secret | string | 是 | 应用程序密钥 |
+| agent_id | int | 是 | 应用程序代理 ID |
+| token | string | 是 | 回调验证令牌 |
+| encoding_aes_key | string | 是 | 43 字符 AES 密钥 |
+| webhook_host | string | 否 | HTTP 服务器绑定地址 |
+| webhook_port | int | 否 | HTTP 服务器端口(默认:18792) |
+| webhook_path | string | 否 | Webhook 路径(默认:/webhook/wecom-app) |
+| allow_from | array | 否 | 用户 ID 白名单 |
+| reply_timeout | int | 否 | 回复超时时间(秒) |
+
+## 设置流程
+
+1. 登录 [企业微信管理后台](https://work.weixin.qq.com/)
+2. 进入“应用管理” -> “创建应用”
+3. 获取企业 ID (CorpID) 和应用 Secret
+4. 在应用设置中配置“接收消息”,获取 Token 和 EncodingAESKey
+5. 设置回调 URL 为 `http://:/webhook/wecom-app`
+6. 将 CorpID, Secret, AgentID 等信息填入配置文件
diff --git a/docs/channels/wecom/wecom_bot/README.zh.md b/docs/channels/wecom/wecom_bot/README.zh.md
new file mode 100644
index 000000000..c4bb1c87e
--- /dev/null
+++ b/docs/channels/wecom/wecom_bot/README.zh.md
@@ -0,0 +1,41 @@
+# 企业微信机器人
+
+企业微信机器人是企业微信提供的一种快速接入方式,可以通过 Webhook URL 接收消息。
+
+## 配置
+
+```json
+{
+ "channels": {
+ "wecom": {
+ "enabled": true,
+ "token": "YOUR_TOKEN",
+ "encoding_aes_key": "YOUR_ENCODING_AES_KEY",
+ "webhook_url": "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY",
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18793,
+ "webhook_path": "/webhook/wecom",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+| 字段 | 类型 | 必填 | 描述 |
+| ---------------- | ------ | ---- | -------------------------------------------- |
+| token | string | 是 | 签名验证代币 |
+| encoding_aes_key | string | 是 | 用于解密的 43 字符 AES 密钥 |
+| webhook_url | string | 是 | 用于发送回复的企业微信群聊机器人 Webhook URL |
+| webhook_host | string | 否 | HTTP 服务器绑定地址(默认:0.0.0.0) |
+| webhook_port | int | 否 | HTTP 服务器端口(默认:18793) |
+| webhook_path | string | 否 | Webhook 端点路径(默认:/webhook/wecom) |
+| allow_from | array | 否 | 用户 ID 白名单(空值 = 允许所有用户) |
+| reply_timeout | int | 否 | 回复超时时间(单位:秒,默认值:5) |
+
+## 设置流程
+
+1. 在企业微信群中添加机器人
+2. 获取 Webhook URL
+3. (如需接收消息) 在机器人配置页面设置接收消息的 API 地址(回调地址)以及 Token 和 EncodingAESKey
+4. 将相关信息填入配置文件
diff --git a/docs/design/provider-refactoring-tests.md b/docs/design/provider-refactoring-tests.md
new file mode 100644
index 000000000..060be9ba8
--- /dev/null
+++ b/docs/design/provider-refactoring-tests.md
@@ -0,0 +1,174 @@
+# Provider Architecture Refactoring - Test Suite Summary
+
+This document summarizes the complete test suite designed for the Provider architecture refactoring.
+
+## Test File Structure
+
+```
+pkg/
+├── config/
+│ ├── model_config_test.go # US-001, US-002: ModelConfig struct and GetModelConfig tests
+│ └── migration_test.go # US-003: Backward compatibility and migration tests
+├── providers/
+│ ├── factory_test.go # US-004, US-005: Provider factory tests
+│ └── factory_provider_test.go # Factory provider integration tests
+```
+
+---
+
+## Test Case Checklist
+
+### 1. `pkg/config/model_config_test.go` - Configuration Parsing Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestModelConfig_Parsing` | Verify ModelConfig JSON parsing | US-001 |
+| `TestModelConfig_ModelListInConfig` | Verify model_list parsing in Config | US-001 |
+| `TestModelConfig_Validation` | Verify required field validation | US-001 |
+| `TestConfig_GetModelConfig_Found` | Verify GetModelConfig finds model | US-002 |
+| `TestConfig_GetModelConfig_NotFound` | Verify GetModelConfig returns error | US-002 |
+| `TestConfig_GetModelConfig_EmptyModelList` | Verify empty model_list handling | US-002 |
+| `TestConfig_BackwardCompatibility_ProvidersToModelList` | Verify old config conversion | US-003 |
+| `TestConfig_DeprecationWarning` | Verify deprecation warning | US-003 |
+| `TestModelConfig_ProtocolExtraction` | Verify protocol prefix extraction | US-004 |
+| `TestConfig_ModelNameUniqueness` | Verify model_name uniqueness | US-001 |
+
+### 2. `pkg/config/migration_test.go` - Migration Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestConvertProvidersToModelList_OpenAI` | OpenAI config conversion | US-003 |
+| `TestConvertProvidersToModelList_Anthropic` | Anthropic config conversion | US-003 |
+| `TestConvertProvidersToModelList_MultipleProviders` | Multiple provider conversion | US-003 |
+| `TestConvertProvidersToModelList_EmptyProviders` | Empty providers handling | US-003 |
+| `TestConvertProvidersToModelList_GitHubCopilot` | GitHub Copilot conversion | US-003 |
+| `TestConvertProvidersToModelList_Antigravity` | Antigravity conversion | US-003 |
+| `TestGenerateModelName_*` | Model name generation | US-003 |
+| `TestHasProvidersConfig_*` | Detect old config existence | US-003 |
+| `TestValidateMigration_*` | Migration validation | US-003 |
+| `TestMigrateConfig_DryRun` | Dry run migration | US-003 |
+| `TestMigrateConfig_Actual` | Actual migration | US-003 |
+
+### 3. `pkg/providers/registry_test.go` - Load Balancing Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestModelRegistry_SingleConfig` | Single config returns same result | US-006 |
+| `TestModelRegistry_RoundRobinSelection` | 3-config round-robin selection | US-006 |
+| `TestModelRegistry_RoundRobinTwoConfigs` | 2-config round-robin selection | US-006 |
+| `TestModelRegistry_ConcurrentAccess` | Concurrent access thread safety | US-006 |
+| `TestModelRegistry_RaceDetection` | Data race detection | US-006 |
+| `TestModelRegistry_ModelNotFound` | Model not found error | US-006 |
+| `TestModelRegistry_EmptyRegistry` | Empty registry handling | US-006 |
+| `TestModelRegistry_MultipleModels` | Multiple model registration | US-006 |
+| `TestModelRegistry_MixedSingleAndMultiple` | Single/multiple config mix | US-006 |
+| `TestModelRegistry_CaseSensitiveModelNames` | Case sensitivity | US-006 |
+
+### 4. `pkg/providers/factory/factory_test.go` - Provider Factory Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestCreateProviderFromConfig_OpenAI` | Create OpenAI provider | US-004 |
+| `TestCreateProviderFromConfig_OpenAIDefault` | Default openai protocol | US-004 |
+| `TestCreateProviderFromConfig_Anthropic` | Create Anthropic provider | US-004 |
+| `TestCreateProviderFromConfig_Antigravity` | Create Antigravity provider | US-004 |
+| `TestCreateProviderFromConfig_ClaudeCLI` | Create Claude CLI provider | US-004 |
+| `TestCreateProviderFromConfig_CodexCLI` | Create Codex CLI provider | US-004 |
+| `TestCreateProviderFromConfig_GitHubCopilot` | Create GitHub Copilot provider | US-004 |
+| `TestCreateProviderFromConfig_UnknownProtocol` | Unknown protocol error handling | US-004 |
+| `TestCreateProviderFromConfig_MissingAPIKey` | Missing API key error | US-004 |
+| `TestExtractProtocol` | Protocol prefix extraction | US-004 |
+| `TestCreateProvider_UsesModelList` | Create using model_list | US-005 |
+| `TestCreateProvider_FallbackToProviders` | Fallback to providers | US-005 |
+| `TestCreateProvider_PriorityModelListOverProviders` | model_list priority | US-005 |
+
+### 5. `pkg/providers/integration_test.go` - E2E Integration Tests
+
+| Test Name | Purpose | PRD Reference |
+|-----------|---------|---------------|
+| `TestE2E_OpenAICompatibleProvider_NoCodeChange` | Zero-code provider addition | Goal |
+| `TestE2E_LoadBalancing_RoundRobin` | Load balancing actual effect | US-006 |
+| `TestE2E_BackwardCompatibility_OldProvidersConfig` | Old config compatibility | US-003 |
+| `TestE2E_ErrorHandling_ModelNotFound` | Model not found | FR-30 |
+| `TestE2E_ErrorHandling_MissingAPIKey` | Missing API key | FR-31 |
+| `TestE2E_ErrorHandling_InvalidAPIBase` | Invalid API base | FR-30 |
+| `TestE2E_ToolCalls_OpenAICompatible` | Tool call support | - |
+| `TestE2E_AntigravityProvider` | Antigravity provider | US-004 |
+| `TestE2E_ClaudeCLIProvider` | Claude CLI provider | US-004 |
+
+### 6. Performance Tests
+
+| Test Name | Purpose |
+|-----------|---------|
+| `BenchmarkCreateProviderFromConfig` | Provider creation performance |
+| `BenchmarkGetModelConfig` | Model lookup performance |
+| `BenchmarkGetModelConfigParallel` | Concurrent lookup performance |
+
+---
+
+## Running Tests
+
+```bash
+# Run all tests
+go test ./pkg/... -v
+
+# Run with data race detection
+go test ./pkg/... -race
+
+# Run specific package tests
+go test ./pkg/config -v
+go test ./pkg/providers -v
+
+# Run E2E tests
+go test ./pkg/providers -run TestE2E -v
+
+# Run performance tests
+go test ./pkg/providers -bench=. -benchmem
+```
+
+---
+
+## PRD Acceptance Criteria Mapping
+
+| PRD Acceptance Criteria | Test Cases |
+|------------------------|------------|
+| US-001: Add ModelConfig struct | `TestModelConfig_Parsing`, `TestModelConfig_Validation` |
+| US-001: model_name unique | `TestConfig_ModelNameUniqueness` |
+| US-002: GetModelConfig method | `TestConfig_GetModelConfig_*` |
+| US-003: Auto-convert providers | `TestConvertProvidersToModelList_*` |
+| US-003: Deprecation warning | `TestConfig_DeprecationWarning` |
+| US-003: Existing tests pass | (existing test files unchanged) |
+| US-004: Protocol prefix factory | `TestExtractProtocol`, `TestCreateProviderFromConfig_*` |
+| US-004: Default prefix openai | `TestCreateProviderFromConfig_OpenAIDefault` |
+| US-005: CreateProvider uses factory | `TestCreateProvider_*` |
+| US-006: Round-robin selection | `TestModelRegistry_RoundRobin*` |
+| US-006: Thread-safe atomic | `TestModelRegistry_RaceDetection` |
+
+---
+
+## Recommended Implementation Order
+
+1. **Phase 1: Configuration Structure** (US-001, US-002)
+ - Implement `ModelConfig` struct
+ - Implement `GetModelConfig` method
+ - Run `model_config_test.go`
+
+2. **Phase 2: Protocol Factory** (US-004)
+ - Implement `CreateProviderFromConfig`
+ - Implement `ExtractProtocol`
+ - Run `factory_test.go`
+
+3. **Phase 3: Load Balancing** (US-006)
+ - Implement `ModelRegistry`
+ - Implement round-robin selection
+ - Run `registry_test.go` (with `-race`)
+
+4. **Phase 4: Backward Compatibility** (US-003, US-005)
+ - Implement `ConvertProvidersToModelList`
+ - Refactor `CreateProvider`
+ - Run `migration_test.go`
+ - Verify existing tests pass
+
+5. **Phase 5: E2E Verification**
+ - Run `integration_test.go`
+ - Manual testing with `config.example.json`
diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md
new file mode 100644
index 000000000..a214d9857
--- /dev/null
+++ b/docs/design/provider-refactoring.md
@@ -0,0 +1,334 @@
+# Provider Architecture Refactoring Design
+
+> Issue: #283
+> Discussion: #122
+> Branch: feat/refactor-provider-by-protocol
+
+## 1. Current Problems
+
+### 1.1 Configuration Structure Issues
+
+**Current State**: Each Provider requires a predefined field in `ProvidersConfig`
+
+```go
+type ProvidersConfig struct {
+ Anthropic ProviderConfig `json:"anthropic"`
+ OpenAI ProviderConfig `json:"openai"`
+ DeepSeek ProviderConfig `json:"deepseek"`
+ Qwen ProviderConfig `json:"qwen"`
+ Cerebras ProviderConfig `json:"cerebras"`
+ VolcEngine ProviderConfig `json:"volcengine"`
+ // ... every new provider requires changes here
+}
+```
+
+**Problems**:
+- Adding a new Provider requires modifying Go code (struct definition)
+- `CreateProvider` function in `http_provider.go` has 200+ lines of switch-case
+- Most Providers are OpenAI-compatible, but code is duplicated
+
+### 1.2 Code Bloat Trend
+
+Recent PRs demonstrate this issue:
+
+| PR | Provider | Code Changes |
+|----|----------|--------------|
+| #365 | Qwen | +17 lines to http_provider.go |
+| #333 | Cerebras | +17 lines to http_provider.go |
+| #368 | Volcengine | +18 lines to http_provider.go |
+
+Each OpenAI-compatible Provider requires:
+1. Modify `config.go` to add configuration field
+2. Modify `http_provider.go` to add switch case
+3. Update documentation
+
+### 1.3 Agent-Provider Coupling
+
+```json
+{
+ "agents": {
+ "defaults": {
+ "provider": "deepseek", // need to know provider name
+ "model": "deepseek-chat"
+ }
+ }
+}
+```
+
+Problem: Agent needs to know both `provider` and `model`, adding complexity.
+
+---
+
+## 2. New Approach: model_list
+
+### 2.1 Core Principles
+
+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`
+3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
+
+### 2.2 New Configuration Structure
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "deepseek-chat",
+ "model": "openai/deepseek-chat",
+ "api_base": "https://api.deepseek.com/v1",
+ "api_key": "sk-xxx"
+ },
+ {
+ "model_name": "gpt-5.2",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-xxx"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-xxx"
+ },
+ {
+ "model_name": "gemini-3-flash",
+ "model": "antigravity/gemini-3-flash",
+ "auth_method": "oauth"
+ },
+ {
+ "model_name": "my-company-llm",
+ "model": "openai/company-model-v1",
+ "api_base": "https://llm.company.com/v1",
+ "api_key": "xxx"
+ }
+ ],
+
+ "agents": {
+ "defaults": {
+ "model": "deepseek-chat",
+ "max_tokens": 8192,
+ "temperature": 0.7
+ }
+ }
+}
+```
+
+### 2.3 Go Struct Definition
+
+```go
+type Config struct {
+ ModelList []ModelConfig `json:"model_list"` // new
+ Providers ProvidersConfig `json:"providers"` // old, deprecated
+
+ Agents AgentsConfig `json:"agents"`
+ Channels ChannelsConfig `json:"channels"`
+ // ...
+}
+
+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
+
+ // Common config
+ APIBase string `json:"api_base,omitempty"`
+ APIKey string `json:"api_key,omitempty"`
+ Proxy string `json:"proxy,omitempty"`
+
+ // Special provider config
+ AuthMethod string `json:"auth_method,omitempty"` // oauth, token
+ ConnectMode string `json:"connect_mode,omitempty"` // stdio, grpc
+
+ // Optional optimizations
+ RPM int `json:"rpm,omitempty"` // rate limit
+ MaxTokensField string `json:"max_tokens_field,omitempty"` // max_tokens or max_completion_tokens
+}
+```
+
+### 2.4 Protocol Recognition
+
+Identify protocol via prefix in `model` field:
+
+| Prefix | Protocol | Description |
+|--------|----------|-------------|
+| `openai/` | OpenAI-compatible | Most common, includes DeepSeek, Qwen, Groq, etc. |
+| `anthropic/` | Anthropic | Claude series specific |
+| `antigravity/` | Antigravity | Google Cloud Code Assist |
+| `gemini/` | Gemini | Google Gemini native API (if needed) |
+
+---
+
+## 3. Design Rationale
+
+### 3.1 Problems Solved
+
+| Problem | Old Approach | New Approach |
+|---------|--------------|--------------|
+| Add OpenAI-compatible Provider | Change 3 code locations | Add one config entry |
+| Agent specifies model | Need provider + model | Only need model |
+| Code duplication | Each Provider duplicates logic | Share protocol implementation |
+| Multi-Agent support | Complex | Naturally compatible |
+
+### 3.2 Multi-Agent Compatibility
+
+```json
+{
+ "model_list": [...],
+
+ "agents": {
+ "defaults": {
+ "model": "deepseek-chat"
+ },
+ "coder": {
+ "model": "gpt-5.2",
+ "system_prompt": "You are a coding assistant..."
+ },
+ "translator": {
+ "model": "claude-sonnet-4.6"
+ }
+ }
+}
+```
+
+Each Agent only needs to specify `model` (corresponds to `model_name` in `model_list`).
+
+### 3.3 Industry Comparison
+
+**LiteLLM** (most mature open-source LLM Proxy) uses similar design:
+
+```yaml
+model_list:
+ - model_name: gpt-4o
+ litellm_params:
+ model: openai/gpt-5.2
+ api_key: xxx
+ - model_name: my-custom
+ litellm_params:
+ model: openai/custom-model
+ api_base: https://my-api.com/v1
+```
+
+---
+
+## 4. Migration Plan
+
+### 4.1 Phase 1: Compatibility Period (v1.x)
+
+Support both `providers` and `model_list`:
+
+```go
+func (c *Config) GetModelConfig(modelName string) (*ModelConfig, error) {
+ // Prefer new config
+ if len(c.ModelList) > 0 {
+ return c.findModelByName(modelName)
+ }
+
+ // Backward compatibility with old config
+ if !c.Providers.IsEmpty() {
+ logger.Warn("'providers' config is deprecated, please migrate to 'model_list'")
+ return c.convertFromProviders(modelName)
+ }
+
+ return nil, fmt.Errorf("model %s not found", modelName)
+}
+```
+
+### 4.2 Phase 2: Warning Period (late v1.x)
+
+- Print more prominent warnings at startup
+- Provide automatic migration script
+- Mark `providers` as deprecated in documentation
+
+### 4.3 Phase 3: Removal Period (v2.0)
+
+- Completely remove `providers` support
+- Remove `agents.defaults.provider` field
+- Only support `model_list`
+
+### 4.4 Configuration Migration Example
+
+**Old Config**:
+```json
+{
+ "providers": {
+ "deepseek": {
+ "api_key": "sk-xxx",
+ "api_base": "https://api.deepseek.com/v1"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "deepseek",
+ "model": "deepseek-chat"
+ }
+ }
+}
+```
+
+**New Config**:
+```json
+{
+ "model_list": [
+ {
+ "model_name": "deepseek-chat",
+ "model": "openai/deepseek-chat",
+ "api_base": "https://api.deepseek.com/v1",
+ "api_key": "sk-xxx"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "deepseek-chat"
+ }
+ }
+}
+```
+
+---
+
+## 5. Implementation Checklist
+
+### 5.1 Configuration Layer
+
+- [ ] Add `ModelConfig` struct
+- [ ] Add `Config.ModelList` field
+- [ ] Implement `GetModelConfig(modelName)` method
+- [ ] Implement old config compatibility conversion
+- [ ] Add `model_name` uniqueness validation
+
+### 5.2 Provider Layer
+
+- [ ] Create `pkg/providers/factory/` directory
+- [ ] Implement `CreateProviderFromModelConfig()`
+- [ ] Refactor `http_provider.go` to `openai/provider.go`
+- [ ] Maintain backward compatibility for old `CreateProvider()`
+
+### 5.3 Testing
+
+- [ ] New config unit tests
+- [ ] Old config compatibility tests
+- [ ] Integration tests
+
+### 5.4 Documentation
+
+- [ ] Update README
+- [ ] Update config.example.json
+- [ ] Write migration guide
+
+---
+
+## 6. Risks and Mitigations
+
+| Risk | Mitigation |
+|------|------------|
+| Breaking existing configs | Compatibility period keeps old config working |
+| User migration cost | Provide automatic migration script |
+| Special Provider incompatibility | Keep `auth_method` and other extension fields |
+
+---
+
+## 7. References
+
+- [LiteLLM Config Documentation](https://docs.litellm.ai/docs/proxy/configs)
+- [One-API GitHub](https://github.com/songquanpeng/one-api)
+- Discussion #122: Refactor Provider Architecture
diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md
new file mode 100644
index 000000000..589dfc043
--- /dev/null
+++ b/docs/migration/model-list-migration.md
@@ -0,0 +1,219 @@
+# Migration Guide: From `providers` to `model_list`
+
+This guide explains how to migrate from the legacy `providers` configuration to the new `model_list` format.
+
+## Why Migrate?
+
+The new `model_list` configuration offers several advantages:
+
+- **Zero-code provider addition**: Add OpenAI-compatible providers with configuration only
+- **Load balancing**: Configure multiple endpoints for the same model
+- **Protocol-based routing**: Use prefixes like `openai/`, `anthropic/`, etc.
+- **Cleaner configuration**: Model-centric instead of vendor-centric
+
+## Timeline
+
+| Version | Status |
+|---------|--------|
+| v1.x | `model_list` introduced, `providers` deprecated but functional |
+| v1.x+1 | Prominent deprecation warnings, migration tool available |
+| v2.0 | `providers` configuration removed |
+
+## Before and After
+
+### Before: Legacy `providers` Configuration
+
+```json
+{
+ "providers": {
+ "openai": {
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ },
+ "anthropic": {
+ "api_key": "sk-ant-your-key"
+ },
+ "deepseek": {
+ "api_key": "sk-your-deepseek-key"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "openai",
+ "model": "gpt-5.2"
+ }
+ }
+}
+```
+
+### After: New `model_list` Configuration
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-your-openai-key",
+ "api_base": "https://api.openai.com/v1"
+ },
+ {
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+ },
+ {
+ "model_name": "deepseek",
+ "model": "deepseek/deepseek-chat",
+ "api_key": "sk-your-deepseek-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "gpt4"
+ }
+ }
+}
+```
+
+## Protocol Prefixes
+
+The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
+
+| Prefix | Description | Example |
+|--------|-------------|---------|
+| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
+| `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` |
+| `claude-cli/` | Claude CLI (local) | `claude-cli/claude-sonnet-4.6` |
+| `codex-cli/` | Codex CLI (local) | `codex-cli/codex-4` |
+| `github-copilot/` | GitHub Copilot | `github-copilot/gpt-4o` |
+| `openrouter/` | OpenRouter | `openrouter/anthropic/claude-sonnet-4.6` |
+| `groq/` | Groq API | `groq/llama-3.1-70b` |
+| `deepseek/` | DeepSeek API | `deepseek/deepseek-chat` |
+| `cerebras/` | Cerebras API | `cerebras/llama-3.3-70b` |
+| `qwen/` | Alibaba Qwen | `qwen/qwen-max` |
+| `zhipu/` | Zhipu AI | `zhipu/glm-4` |
+| `nvidia/` | NVIDIA NIM | `nvidia/llama-3.1-nemotron-70b` |
+| `ollama/` | Ollama (local) | `ollama/llama3` |
+| `vllm/` | vLLM (local) | `vllm/my-model` |
+| `moonshot/` | Moonshot AI | `moonshot/moonshot-v1-8k` |
+| `shengsuanyun/` | ShengSuanYun | `shengsuanyun/deepseek-v3` |
+| `volcengine/` | Volcengine | `volcengine/doubao-pro-32k` |
+
+**Note**: If no prefix is specified, `openai/` is used as the default.
+
+## ModelConfig Fields
+
+| Field | Required | Description |
+|-------|----------|-------------|
+| `model_name` | Yes | User-facing alias for the model |
+| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
+| `api_base` | No | API endpoint URL |
+| `api_key` | No* | API authentication key |
+| `proxy` | No | HTTP proxy URL |
+| `auth_method` | No | Authentication method: `oauth`, `token` |
+| `connect_mode` | No | Connection mode for CLI providers: `stdio`, `grpc` |
+| `rpm` | No | Requests per minute limit |
+| `max_tokens_field` | No | Field name for max tokens |
+
+*`api_key` is required for HTTP-based protocols unless `api_base` points to a local server.
+
+## Load Balancing
+
+Configure multiple endpoints for the same model to distribute load:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key1",
+ "api_base": "https://api1.example.com/v1"
+ },
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key2",
+ "api_base": "https://api2.example.com/v1"
+ },
+ {
+ "model_name": "gpt4",
+ "model": "openai/gpt-5.2",
+ "api_key": "sk-key3",
+ "api_base": "https://api3.example.com/v1"
+ }
+ ]
+}
+```
+
+When you request model `gpt4`, requests will be distributed across all three endpoints using round-robin selection.
+
+## Adding a New OpenAI-Compatible Provider
+
+With `model_list`, adding a new provider requires zero code changes:
+
+```json
+{
+ "model_list": [
+ {
+ "model_name": "my-custom-llm",
+ "model": "openai/my-model-v1",
+ "api_key": "your-api-key",
+ "api_base": "https://api.your-provider.com/v1"
+ }
+ ]
+}
+```
+
+Just specify `openai/` as the protocol (or omit it for the default), and provide your provider's API base URL.
+
+## Backward Compatibility
+
+During the migration period, your existing `providers` configuration will continue to work:
+
+1. If `model_list` is empty and `providers` has data, the system auto-converts internally
+2. A deprecation warning is logged: `"providers config is deprecated, please migrate to model_list"`
+3. All existing functionality remains unchanged
+
+## Migration Checklist
+
+- [ ] Identify all providers you're currently using
+- [ ] Create `model_list` entries for each provider
+- [ ] Use appropriate protocol prefixes
+- [ ] Update `agents.defaults.model` to reference the new `model_name`
+- [ ] Test that all models work correctly
+- [ ] Remove or comment out the old `providers` section
+
+## Troubleshooting
+
+### Model not found error
+
+```
+model "xxx" not found in model_list or providers
+```
+
+**Solution**: Ensure the `model_name` in `model_list` matches the value in `agents.defaults.model`.
+
+### Unknown protocol error
+
+```
+unknown protocol "xxx" in model "xxx/model-name"
+```
+
+**Solution**: Use a supported protocol prefix. See the [Protocol Prefixes](#protocol-prefixes) table above.
+
+### Missing API key error
+
+```
+api_key or api_base is required for HTTP-based protocol "xxx"
+```
+
+**Solution**: Provide `api_key` and/or `api_base` for HTTP-based providers.
+
+## Need Help?
+
+- [GitHub Issues](https://github.com/sipeed/picoclaw/issues)
+- [Discussion #122](https://github.com/sipeed/picoclaw/discussions/122): Original proposal
diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md
new file mode 100644
index 000000000..8aba1aa91
--- /dev/null
+++ b/docs/tools_configuration.md
@@ -0,0 +1,143 @@
+# Tools Configuration
+
+PicoClaw's tools configuration is located in the `tools` field of `config.json`.
+
+## Directory Structure
+
+```json
+{
+ "tools": {
+ "web": { ... },
+ "exec": { ... },
+ "cron": { ... },
+ "skills": { ... }
+ }
+}
+```
+
+## Web Tools
+
+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 |
+
+### 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 |
+
+## Exec Tool
+
+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) |
+
+### Functionality
+
+- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns
+- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked
+
+### Default Blocked Command Patterns
+
+By default, PicoClaw blocks the following dangerous commands:
+
+- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s`
+- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*`
+- System operations: `shutdown`, `reboot`, `poweroff`
+- Command substitution: `$()`, `${}`, backticks
+- Pipe to shell: `| sh`, `| bash`
+- Privilege escalation: `sudo`, `chmod`, `chown`
+- Process control: `pkill`, `killall`, `kill -9`
+- Remote operations: `curl | sh`, `wget | sh`, `ssh`
+- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user`
+- Containers: `docker run`, `docker exec`
+- Git: `git push`, `git force`
+- Other: `eval`, `source *.sh`
+
+### Configuration Example
+
+```json
+{
+ "tools": {
+ "exec": {
+ "enable_deny_patterns": true,
+ "custom_deny_patterns": [
+ "\\brm\\s+-r\\b",
+ "\\bkillall\\s+python"
+ ]
+ }
+ }
+}
+```
+
+## Cron Tool
+
+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 |
+
+## 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 |
+| `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
+
+```json
+{
+ "tools": {
+ "skills": {
+ "registries": {
+ "clawhub": {
+ "enabled": true,
+ "base_url": "https://clawhub.ai",
+ "search_path": "/api/v1/search",
+ "skills_path": "/api/v1/skills",
+ "download_path": "/api/v1/download"
+ }
+ }
+ }
+ }
+}
+```
+
+## Environment Variables
+
+All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS__`:
+
+For example:
+- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true`
+- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false`
+- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
+
+Note: Array-type environment variables are not currently supported and must be set via the config file.
diff --git a/docs/wecom-app-configuration.md b/docs/wecom-app-configuration.md
new file mode 100644
index 000000000..3b17d37a7
--- /dev/null
+++ b/docs/wecom-app-configuration.md
@@ -0,0 +1,117 @@
+# 企业微信自建应用 (WeCom App) 配置指南
+
+本文档介绍如何在 PicoClaw 中配置企业微信自建应用 (wecom-app) 通道。
+
+## 功能特性
+
+| 功能 | 支持状态 |
+|------|---------|
+| 被动接收消息 | ✅ |
+| 主动发送消息 | ✅ |
+| 私聊 | ✅ |
+| 群聊 | ❌ |
+
+## 配置步骤
+
+### 1. 企业微信后台配置
+
+1. 登录 [企业微信管理后台](https://work.weixin.qq.com/wework_admin)
+2. 进入"应用管理" → 选择自建应用
+3. 记录以下信息:
+ - **AgentId**: 应用详情页显示
+ - **Secret**: 点击"查看"获取
+4. 进入"我的企业"页面,记录 **企业ID** (CorpID)
+
+### 2. 接收消息配置
+
+1. 在应用详情页,点击"接收消息"的"设置API接收"
+2. 填写以下信息:
+ - **URL**: `http://your-server:18792/webhook/wecom-app`
+ - **Token**: 随机生成或自定义(用于签名验证)
+ - **EncodingAESKey**: 点击"随机生成"生成43字符的密钥
+3. 点击"保存"时,企业微信会发送验证请求
+
+### 3. PicoClaw 配置
+
+在 `config.json` 中添加以下配置:
+
+```json
+{
+ "channels": {
+ "wecom_app": {
+ "enabled": true,
+ "corp_id": "wwxxxxxxxxxxxxxxxx", // 企业ID
+ "corp_secret": "xxxxxxxxxxxxxxxxxxxxxxxx", // 应用Secret
+ "agent_id": 1000002, // 应用AgentId
+ "token": "your_token", // 接收消息配置的Token
+ "encoding_aes_key": "your_encoding_aes_key", // 接收消息配置的EncodingAESKey
+ "webhook_host": "0.0.0.0",
+ "webhook_port": 18792,
+ "webhook_path": "/webhook/wecom-app",
+ "allow_from": [],
+ "reply_timeout": 5
+ }
+ }
+}
+```
+
+## 常见问题
+
+### 1. 回调URL验证失败
+
+**症状**: 企业微信保存API接收消息时提示验证失败
+
+**检查项**:
+- 确认服务器防火墙已开放 18792 端口
+- 确认 `corp_id`、`token`、`encoding_aes_key` 配置正确
+- 查看 PicoClaw 日志是否有请求到达
+
+### 2. 中文消息解密失败
+
+**症状**: 发送中文消息时出现 `invalid padding size` 错误
+
+**原因**: 企业微信使用非标准的 PKCS7 填充(32字节块大小)
+
+**解决**: 确保使用最新版本的 PicoClaw,已修复此问题。
+
+### 3. 端口冲突
+
+**症状**: 启动时提示端口已被占用
+
+**解决**: 修改 `webhook_port` 为其他端口,如 18794
+
+## 技术细节
+
+### 加密算法
+
+- **算法**: AES-256-CBC
+- **密钥**: EncodingAESKey Base64解码后的32字节
+- **IV**: AESKey的前16字节
+- **填充**: PKCS7(块大小为32字节,非标准16字节)
+- **消息格式**: XML
+
+### 消息结构
+
+解密后的消息格式:
+```
+random(16B) + msg_len(4B) + msg + receiveid
+```
+
+其中 `receiveid` 对于自建应用是 `corp_id`。
+
+## 调试
+
+启用调试模式查看详细日志:
+
+```bash
+picoclaw gateway --debug
+```
+
+关键日志标识:
+- `wecom_app`: WeCom App 通道相关日志
+- `wecom_common`: 加密解密相关日志
+
+## 参考文档
+
+- [企业微信官方文档 - 接收消息](https://developer.work.weixin.qq.com/document/path/96211)
+- [企业微信官方加解密库](https://github.com/sbzhu/weworkapi_golang)
diff --git a/go.mod b/go.mod
index 98aecd6ab..1f88639c8 100644
--- a/go.mod
+++ b/go.mod
@@ -15,11 +15,16 @@ require (
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
github.com/openai/openai-go/v3 v3.22.0
github.com/slack-go/slack v0.17.3
+ github.com/stretchr/testify v1.11.1
github.com/tencent-connect/botgo v0.2.1
golang.org/x/oauth2 v0.35.0
)
-
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
@@ -28,9 +33,9 @@ require (
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/github/copilot-sdk/go v0.1.23
- github.com/google/jsonschema-go v0.4.2 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/google/jsonschema-go v0.4.2 // indirect
github.com/grbit/go-json v0.11.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
@@ -47,5 +52,4 @@ require (
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
-
)
diff --git a/go.sum b/go.sum
index 6a565b93e..0e95bf5cd 100644
--- a/go.sum
+++ b/go.sum
@@ -58,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
+github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -78,9 +80,11 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
@@ -102,6 +106,7 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
+github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
@@ -242,6 +247,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
diff --git a/pkg/agent/context.go b/pkg/agent/context.go
index cf5ce2913..ba07e33d3 100644
--- a/pkg/agent/context.go
+++ b/pkg/agent/context.go
@@ -80,7 +80,7 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
-3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
+3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md`,
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
}
@@ -96,7 +96,9 @@ func (cb *ContextBuilder) buildToolsSection() string {
var sb strings.Builder
sb.WriteString("## Available Tools\n\n")
- sb.WriteString("**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n")
+ sb.WriteString(
+ "**CRITICAL**: You MUST use tools to perform actions. Do NOT pretend to execute commands or schedule tasks.\n\n",
+ )
sb.WriteString("You have access to the following tools:\n\n")
for _, s := range summaries {
sb.WriteString(s)
@@ -146,18 +148,24 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
"IDENTITY.md",
}
- var result string
+ var sb strings.Builder
for _, filename := range bootstrapFiles {
filePath := filepath.Join(cb.workspace, filename)
if data, err := os.ReadFile(filePath); err == nil {
- result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
+ fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data)
}
}
- return result
+ return sb.String()
}
-func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary string, currentMessage string, media []string, channel, chatID string) []providers.Message {
+func (cb *ContextBuilder) BuildMessages(
+ history []providers.Message,
+ summary string,
+ currentMessage string,
+ media []string,
+ channel, chatID string,
+) []providers.Message {
messages := []providers.Message{}
systemPrompt := cb.BuildSystemPrompt()
@@ -169,7 +177,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
// Log system prompt summary for debugging (debug mode only)
logger.DebugCF("agent", "System prompt built",
- map[string]interface{}{
+ map[string]any{
"total_chars": len(systemPrompt),
"total_lines": strings.Count(systemPrompt, "\n") + 1,
"section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1,
@@ -181,7 +189,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
preview = preview[:500] + "... (truncated)"
}
logger.DebugCF("agent", "System prompt preview",
- map[string]interface{}{
+ map[string]any{
"preview": preview,
})
@@ -189,16 +197,7 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
}
- //This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM
- // --- INICIO DEL FIX ---
- //Diegox-17
- for len(history) > 0 && (history[0].Role == "tool") {
- logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error",
- map[string]interface{}{"role": history[0].Role})
- history = history[1:]
- }
- //Diegox-17
- // --- FIN DEL FIX ---
+ history = sanitizeHistoryForProvider(history)
messages = append(messages, providers.Message{
Role: "system",
@@ -207,15 +206,66 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
messages = append(messages, history...)
- messages = append(messages, providers.Message{
- Role: "user",
- Content: currentMessage,
- })
+ if strings.TrimSpace(currentMessage) != "" {
+ messages = append(messages, providers.Message{
+ Role: "user",
+ Content: currentMessage,
+ })
+ }
return messages
}
-func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
+func sanitizeHistoryForProvider(history []providers.Message) []providers.Message {
+ if len(history) == 0 {
+ return history
+ }
+
+ sanitized := make([]providers.Message, 0, len(history))
+ for _, msg := range history {
+ switch msg.Role {
+ case "tool":
+ if len(sanitized) == 0 {
+ logger.DebugCF("agent", "Dropping orphaned leading tool message", map[string]any{})
+ continue
+ }
+ last := sanitized[len(sanitized)-1]
+ if last.Role != "assistant" || len(last.ToolCalls) == 0 {
+ logger.DebugCF("agent", "Dropping orphaned tool message", map[string]any{})
+ continue
+ }
+ sanitized = append(sanitized, msg)
+
+ case "assistant":
+ if len(msg.ToolCalls) > 0 {
+ if len(sanitized) == 0 {
+ logger.DebugCF("agent", "Dropping assistant tool-call turn at history start", map[string]any{})
+ continue
+ }
+ prev := sanitized[len(sanitized)-1]
+ if prev.Role != "user" && prev.Role != "tool" {
+ logger.DebugCF(
+ "agent",
+ "Dropping assistant tool-call turn with invalid predecessor",
+ map[string]any{"prev_role": prev.Role},
+ )
+ continue
+ }
+ }
+ sanitized = append(sanitized, msg)
+
+ default:
+ sanitized = append(sanitized, msg)
+ }
+ }
+
+ return sanitized
+}
+
+func (cb *ContextBuilder) AddToolResult(
+ messages []providers.Message,
+ toolCallID, toolName, result string,
+) []providers.Message {
messages = append(messages, providers.Message{
Role: "tool",
Content: result,
@@ -224,7 +274,11 @@ func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID
return messages
}
-func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message {
+func (cb *ContextBuilder) AddAssistantMessage(
+ messages []providers.Message,
+ content string,
+ toolCalls []map[string]any,
+) []providers.Message {
msg := providers.Message{
Role: "assistant",
Content: content,
@@ -234,33 +288,14 @@ func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, cont
return messages
}
-func (cb *ContextBuilder) loadSkills() string {
- allSkills := cb.skillsLoader.ListSkills()
- if len(allSkills) == 0 {
- return ""
- }
-
- var skillNames []string
- for _, s := range allSkills {
- skillNames = append(skillNames, s.Name)
- }
-
- content := cb.skillsLoader.LoadSkillsForContext(skillNames)
- if content == "" {
- return ""
- }
-
- return "# Skill Definitions\n\n" + content
-}
-
// GetSkillsInfo returns information about loaded skills.
-func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
+func (cb *ContextBuilder) GetSkillsInfo() map[string]any {
allSkills := cb.skillsLoader.ListSkills()
skillNames := make([]string, 0, len(allSkills))
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
- return map[string]interface{}{
+ return map[string]any{
"total": len(allSkills),
"available": len(allSkills),
"names": skillNames,
diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go
new file mode 100644
index 000000000..c6a54c7d2
--- /dev/null
+++ b/pkg/agent/instance.go
@@ -0,0 +1,159 @@
+package agent
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/routing"
+ "github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/tools"
+)
+
+// AgentInstance represents a fully configured agent with its own workspace,
+// session manager, context builder, and tool registry.
+type AgentInstance struct {
+ ID string
+ Name string
+ Model string
+ Fallbacks []string
+ Workspace string
+ MaxIterations int
+ MaxTokens int
+ Temperature float64
+ ContextWindow int
+ Provider providers.LLMProvider
+ Sessions *session.SessionManager
+ ContextBuilder *ContextBuilder
+ Tools *tools.ToolRegistry
+ Subagents *config.SubagentsConfig
+ SkillsFilter []string
+ Candidates []providers.FallbackCandidate
+}
+
+// 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
+ toolsRegistry := tools.NewToolRegistry()
+ toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg))
+ toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict))
+ toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
+
+ sessionsDir := filepath.Join(workspace, "sessions")
+ sessionsManager := session.NewSessionManager(sessionsDir)
+
+ contextBuilder := NewContextBuilder(workspace)
+ contextBuilder.SetToolsRegistry(toolsRegistry)
+
+ 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
+ }
+
+ maxIter := defaults.MaxToolIterations
+ if maxIter == 0 {
+ maxIter = 20
+ }
+
+ maxTokens := defaults.MaxTokens
+ if maxTokens == 0 {
+ maxTokens = 8192
+ }
+
+ temperature := 0.7
+ if defaults.Temperature != nil {
+ temperature = *defaults.Temperature
+ }
+
+ // Resolve fallback candidates
+ modelCfg := providers.ModelConfig{
+ Primary: model,
+ Fallbacks: fallbacks,
+ }
+ candidates := providers.ResolveCandidates(modelCfg, defaults.Provider)
+
+ return &AgentInstance{
+ ID: agentID,
+ Name: agentName,
+ Model: model,
+ Fallbacks: fallbacks,
+ Workspace: workspace,
+ MaxIterations: maxIter,
+ MaxTokens: maxTokens,
+ Temperature: temperature,
+ ContextWindow: maxTokens,
+ Provider: provider,
+ Sessions: sessionsManager,
+ ContextBuilder: contextBuilder,
+ Tools: toolsRegistry,
+ Subagents: subagents,
+ SkillsFilter: skillsFilter,
+ Candidates: candidates,
+ }
+}
+
+// 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))
+ }
+ if agentCfg == nil || agentCfg.Default || agentCfg.ID == "" || routing.NormalizeAgentID(agentCfg.ID) == "main" {
+ return expandHome(defaults.Workspace)
+ }
+ 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
+}
+
+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
+}
diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go
new file mode 100644
index 000000000..fcc8e9bea
--- /dev/null
+++ b/pkg/agent/instance_test.go
@@ -0,0 +1,95 @@
+package agent
+
+import (
+ "os"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestNewAgentInstance_UsesDefaultsTemperatureAndMaxTokens(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: "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)
+ }
+}
+
+func TestNewAgentInstance_DefaultsTemperatureWhenZero(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: "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 {
+ t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.0)
+ }
+}
+
+func TestNewAgentInstance_DefaultsTemperatureWhenUnset(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: "test-model",
+ MaxTokens: 1234,
+ MaxToolIterations: 5,
+ },
+ },
+ }
+
+ provider := &mockProvider{}
+ agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, provider)
+
+ if agent.Temperature != 0.7 {
+ t.Fatalf("Temperature = %f, want %f", agent.Temperature, 0.7)
+ }
+}
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index f3dd94090..9a2bb1198 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -10,8 +10,6 @@ import (
"context"
"encoding/json"
"fmt"
- "os"
- "path/filepath"
"strings"
"sync"
"sync/atomic"
@@ -19,11 +17,13 @@ import (
"unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/providers"
- "github.com/sipeed/picoclaw/pkg/session"
+ "github.com/sipeed/picoclaw/pkg/routing"
+ "github.com/sipeed/picoclaw/pkg/skills"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
@@ -31,17 +31,13 @@ import (
type AgentLoop struct {
bus *bus.MessageBus
- provider providers.LLMProvider
- workspace string
- model string
- contextWindow int // Maximum context window size in tokens
- maxIterations int
- sessions *session.SessionManager
+ cfg *config.Config
+ registry *AgentRegistry
state *state.Manager
- contextBuilder *ContextBuilder
- tools *tools.ToolRegistry
running atomic.Bool
- summarizing sync.Map // Tracks which sessions are currently being summarized
+ summarizing sync.Map
+ fallback *providers.FallbackChain
+ channelManager *channels.Manager
}
// processOptions configures how a message is processed
@@ -56,96 +52,105 @@ type processOptions struct {
NoHistory bool // If true, don't load session history (for heartbeat)
}
-// createToolRegistry creates a tool registry with common tools.
-// This is shared between main agent and subagents.
-func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msgBus *bus.MessageBus) *tools.ToolRegistry {
- registry := tools.NewToolRegistry()
-
- // File system tools
- registry.Register(tools.NewReadFileTool(workspace, restrict))
- registry.Register(tools.NewWriteFileTool(workspace, restrict))
- registry.Register(tools.NewListDirTool(workspace, restrict))
- registry.Register(tools.NewEditFileTool(workspace, restrict))
- registry.Register(tools.NewAppendFileTool(workspace, restrict))
-
- // Shell execution
- registry.Register(tools.NewExecTool(workspace, restrict))
-
- if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
- BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
- BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
- BraveEnabled: cfg.Tools.Web.Brave.Enabled,
- DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
- DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
- }); searchTool != nil {
- registry.Register(searchTool)
- }
- registry.Register(tools.NewWebFetchTool(50000))
-
- // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
- registry.Register(tools.NewI2CTool())
- registry.Register(tools.NewSPITool())
-
- // Message tool - available to both agent and subagent
- // Subagent uses it to communicate directly with user
- messageTool := tools.NewMessageTool()
- messageTool.SetSendCallback(func(channel, chatID, content string) error {
- msgBus.PublishOutbound(bus.OutboundMessage{
- Channel: channel,
- ChatID: chatID,
- Content: content,
- })
- return nil
- })
- registry.Register(messageTool)
-
- return registry
-}
-
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
- workspace := cfg.WorkspacePath()
- os.MkdirAll(workspace, 0755)
+ registry := NewAgentRegistry(cfg, provider)
- restrict := cfg.Agents.Defaults.RestrictToWorkspace
+ // Register shared tools to all agents
+ registerSharedTools(cfg, msgBus, registry, provider)
- // Create tool registry for main agent
- toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus)
+ // Set up shared fallback chain
+ cooldown := providers.NewCooldownTracker()
+ fallbackChain := providers.NewFallbackChain(cooldown)
- // Create subagent manager with its own tool registry
- subagentManager := tools.NewSubagentManager(provider, cfg.Agents.Defaults.Model, workspace, msgBus)
- subagentTools := createToolRegistry(workspace, restrict, cfg, msgBus)
- // Subagent doesn't need spawn/subagent tools to avoid recursion
- subagentManager.SetTools(subagentTools)
-
- // Register spawn tool (for main agent)
- spawnTool := tools.NewSpawnTool(subagentManager)
- toolsRegistry.Register(spawnTool)
-
- // Register subagent tool (synchronous execution)
- subagentTool := tools.NewSubagentTool(subagentManager)
- toolsRegistry.Register(subagentTool)
-
- sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions"))
-
- // Create state manager for atomic state persistence
- stateManager := state.NewManager(workspace)
-
- // Create context builder and set tools registry
- contextBuilder := NewContextBuilder(workspace)
- contextBuilder.SetToolsRegistry(toolsRegistry)
+ // Create state manager using default agent's workspace for channel recording
+ defaultAgent := registry.GetDefaultAgent()
+ var stateManager *state.Manager
+ if defaultAgent != nil {
+ stateManager = state.NewManager(defaultAgent.Workspace)
+ }
return &AgentLoop{
- bus: msgBus,
- provider: provider,
- workspace: workspace,
- model: cfg.Agents.Defaults.Model,
- contextWindow: cfg.Agents.Defaults.MaxTokens, // Restore context window for summarization
- maxIterations: cfg.Agents.Defaults.MaxToolIterations,
- sessions: sessionsManager,
- state: stateManager,
- contextBuilder: contextBuilder,
- tools: toolsRegistry,
- summarizing: sync.Map{},
+ bus: msgBus,
+ cfg: cfg,
+ registry: registry,
+ state: stateManager,
+ summarizing: sync.Map{},
+ fallback: fallbackChain,
+ }
+}
+
+// registerSharedTools registers tools that are shared across all agents (web, message, spawn).
+func registerSharedTools(
+ cfg *config.Config,
+ msgBus *bus.MessageBus,
+ registry *AgentRegistry,
+ provider providers.LLMProvider,
+) {
+ for _, agentID := range registry.ListAgentIDs() {
+ agent, ok := registry.GetAgent(agentID)
+ if !ok {
+ continue
+ }
+
+ // Web tools
+ if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{
+ BraveAPIKey: cfg.Tools.Web.Brave.APIKey,
+ BraveMaxResults: cfg.Tools.Web.Brave.MaxResults,
+ BraveEnabled: cfg.Tools.Web.Brave.Enabled,
+ TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey,
+ TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL,
+ TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults,
+ TavilyEnabled: cfg.Tools.Web.Tavily.Enabled,
+ DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
+ DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
+ PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
+ PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
+ PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
+ }); searchTool != nil {
+ agent.Tools.Register(searchTool)
+ }
+ agent.Tools.Register(tools.NewWebFetchTool(50000))
+
+ // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
+ agent.Tools.Register(tools.NewI2CTool())
+ agent.Tools.Register(tools.NewSPITool())
+
+ // Message tool
+ messageTool := tools.NewMessageTool()
+ messageTool.SetSendCallback(func(channel, chatID, content string) error {
+ msgBus.PublishOutbound(bus.OutboundMessage{
+ Channel: channel,
+ ChatID: chatID,
+ Content: content,
+ })
+ return nil
+ })
+ agent.Tools.Register(messageTool)
+
+ // Skill discovery and installation tools
+ registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
+ MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
+ ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub),
+ })
+ searchCache := skills.NewSearchCache(
+ cfg.Tools.Skills.SearchCache.MaxSize,
+ time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
+ )
+ agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
+ agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace))
+
+ // Spawn tool with allowlist checker
+ subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace, msgBus)
+ subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
+ spawnTool := tools.NewSpawnTool(subagentManager)
+ currentAgentID := agentID
+ spawnTool.SetAllowlistChecker(func(targetAgentID string) bool {
+ return registry.CanSpawnSubagent(currentAgentID, targetAgentID)
+ })
+ agent.Tools.Register(spawnTool)
+
+ // Update context builder with the complete tools registry
+ agent.ContextBuilder.SetToolsRegistry(agent.Tools)
}
}
@@ -170,10 +175,14 @@ func (al *AgentLoop) Run(ctx context.Context) error {
if response != "" {
// Check if the message tool already sent a response during this round.
// If so, skip publishing to avoid duplicate messages to the user.
+ // Use default agent's tools to check (message tool is shared).
alreadySent := false
- if tool, ok := al.tools.Get("message"); ok {
- if mt, ok := tool.(*tools.MessageTool); ok {
- alreadySent = mt.HasSentInRound()
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent != nil {
+ if tool, ok := defaultAgent.Tools.Get("message"); ok {
+ if mt, ok := tool.(*tools.MessageTool); ok {
+ alreadySent = mt.HasSentInRound()
+ }
}
}
@@ -196,18 +205,32 @@ func (al *AgentLoop) Stop() {
}
func (al *AgentLoop) RegisterTool(tool tools.Tool) {
- al.tools.Register(tool)
+ for _, agentID := range al.registry.ListAgentIDs() {
+ if agent, ok := al.registry.GetAgent(agentID); ok {
+ agent.Tools.Register(tool)
+ }
+ }
+}
+
+func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
+ al.channelManager = cm
}
// RecordLastChannel records the last active channel for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChannel(channel string) error {
+ if al.state == nil {
+ return nil
+ }
return al.state.SetLastChannel(channel)
}
// RecordLastChatID records the last active chat ID for this workspace.
// This uses the atomic state save mechanism to prevent data loss on crash.
func (al *AgentLoop) RecordLastChatID(chatID string) error {
+ if al.state == nil {
+ return nil
+ }
return al.state.SetLastChatID(chatID)
}
@@ -215,7 +238,10 @@ func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey stri
return al.ProcessDirectWithChannel(ctx, content, sessionKey, "cli", "direct")
}
-func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sessionKey, channel, chatID string) (string, error) {
+func (al *AgentLoop) ProcessDirectWithChannel(
+ ctx context.Context,
+ content, sessionKey, channel, chatID string,
+) (string, error) {
msg := bus.InboundMessage{
Channel: channel,
SenderID: "cron",
@@ -230,7 +256,8 @@ func (al *AgentLoop) ProcessDirectWithChannel(ctx context.Context, content, sess
// ProcessHeartbeat processes a heartbeat request without session history.
// Each heartbeat is independent and doesn't accumulate context.
func (al *AgentLoop) ProcessHeartbeat(ctx context.Context, content, channel, chatID string) (string, error) {
- return al.runAgentLoop(ctx, processOptions{
+ agent := al.registry.GetDefaultAgent()
+ return al.runAgentLoop(ctx, agent, processOptions{
SessionKey: "heartbeat",
Channel: channel,
ChatID: chatID,
@@ -251,7 +278,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
logContent = utils.Truncate(msg.Content, 80)
}
logger.InfoCF("agent", fmt.Sprintf("Processing message from %s:%s: %s", msg.Channel, msg.SenderID, logContent),
- map[string]interface{}{
+ map[string]any{
"channel": msg.Channel,
"chat_id": msg.ChatID,
"sender_id": msg.SenderID,
@@ -263,9 +290,41 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.processSystemMessage(ctx, msg)
}
- // Process as user message
- return al.runAgentLoop(ctx, processOptions{
- SessionKey: msg.SessionKey,
+ // Check for commands
+ if response, handled := al.handleCommand(ctx, msg); handled {
+ return response, nil
+ }
+
+ // Route to determine agent and session key
+ route := al.registry.ResolveRoute(routing.RouteInput{
+ Channel: msg.Channel,
+ AccountID: msg.Metadata["account_id"],
+ Peer: extractPeer(msg),
+ ParentPeer: extractParentPeer(msg),
+ GuildID: msg.Metadata["guild_id"],
+ TeamID: msg.Metadata["team_id"],
+ })
+
+ agent, ok := al.registry.GetAgent(route.AgentID)
+ if !ok {
+ agent = al.registry.GetDefaultAgent()
+ }
+
+ // Use routed session key, but honor pre-set agent-scoped keys (for ProcessDirect/cron)
+ sessionKey := route.SessionKey
+ if msg.SessionKey != "" && strings.HasPrefix(msg.SessionKey, "agent:") {
+ sessionKey = msg.SessionKey
+ }
+
+ logger.InfoCF("agent", "Routed message",
+ map[string]any{
+ "agent_id": agent.ID,
+ "session_key": sessionKey,
+ "matched_by": route.MatchedBy,
+ })
+
+ return al.runAgentLoop(ctx, agent, processOptions{
+ SessionKey: sessionKey,
Channel: msg.Channel,
ChatID: msg.ChatID,
UserMessage: msg.Content,
@@ -276,24 +335,24 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}
func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
- // Verify this is a system message
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]interface{}{
+ map[string]any{
"sender_id": msg.SenderID,
"chat_id": msg.ChatID,
})
// Parse origin channel from chat_id (format: "channel:chat_id")
- var originChannel string
+ var originChannel, originChatID string
if idx := strings.Index(msg.ChatID, ":"); idx > 0 {
originChannel = msg.ChatID[:idx]
+ originChatID = msg.ChatID[idx+1:]
} else {
- // Fallback
originChannel = "cli"
+ originChatID = msg.ChatID
}
// Extract subagent result from message content
@@ -306,7 +365,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
// Skip internal channels - only log, don't send to user
if constants.IsInternalChannel(originChannel) {
logger.InfoCF("agent", "Subagent completed (internal channel)",
- map[string]interface{}{
+ map[string]any{
"sender_id": msg.SenderID,
"content_len": len(content),
"channel": originChannel,
@@ -314,44 +373,47 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
return "", nil
}
- // Agent acts as dispatcher only - subagent handles user interaction via message tool
- // Don't forward result here, subagent should use message tool to communicate with user
- logger.InfoCF("agent", "Subagent completed",
- map[string]interface{}{
- "sender_id": msg.SenderID,
- "channel": originChannel,
- "content_len": len(content),
- })
+ // Use default agent for system messages
+ agent := al.registry.GetDefaultAgent()
- // Agent only logs, does not respond to user
- return "", nil
+ // Use the origin session for context
+ sessionKey := routing.BuildAgentMainSessionKey(agent.ID)
+
+ return al.runAgentLoop(ctx, agent, processOptions{
+ SessionKey: sessionKey,
+ Channel: originChannel,
+ ChatID: originChatID,
+ UserMessage: fmt.Sprintf("[System: %s] %s", msg.SenderID, msg.Content),
+ DefaultResponse: "Background task completed.",
+ EnableSummary: false,
+ SendResponse: true,
+ })
}
// runAgentLoop is the core message processing logic.
-// It handles context building, LLM calls, tool execution, and response handling.
-func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (string, error) {
+func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opts processOptions) (string, error) {
// 0. Record last channel for heartbeat notifications (skip internal channels)
if opts.Channel != "" && opts.ChatID != "" {
// Don't record internal channels (cli, system, subagent)
if !constants.IsInternalChannel(opts.Channel) {
channelKey := fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID)
if err := al.RecordLastChannel(channelKey); err != nil {
- logger.WarnCF("agent", "Failed to record last channel: %v", map[string]interface{}{"error": err.Error()})
+ logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()})
}
}
}
// 1. Update tool contexts
- al.updateToolContexts(opts.Channel, opts.ChatID)
+ al.updateToolContexts(agent, opts.Channel, opts.ChatID)
// 2. Build messages (skip history for heartbeat)
var history []providers.Message
var summary string
if !opts.NoHistory {
- history = al.sessions.GetHistory(opts.SessionKey)
- summary = al.sessions.GetSummary(opts.SessionKey)
+ history = agent.Sessions.GetHistory(opts.SessionKey)
+ summary = agent.Sessions.GetSummary(opts.SessionKey)
}
- messages := al.contextBuilder.BuildMessages(
+ messages := agent.ContextBuilder.BuildMessages(
history,
summary,
opts.UserMessage,
@@ -361,10 +423,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
)
// 3. Save user message to session
- al.sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
+ agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
// 4. Run LLM iteration loop
- finalContent, iteration, err := al.runLLMIteration(ctx, messages, opts)
+ finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
if err != nil {
return "", err
}
@@ -378,12 +440,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
}
// 6. Save final assistant message to session
- al.sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
- al.sessions.Save(opts.SessionKey)
+ agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent)
+ agent.Sessions.Save(opts.SessionKey)
// 7. Optional: summarization
if opts.EnableSummary {
- al.maybeSummarize(opts.SessionKey)
+ al.maybeSummarize(agent, opts.SessionKey, opts.Channel, opts.ChatID)
}
// 8. Optional: send response via bus
@@ -398,7 +460,8 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
// 9. Log response
responsePreview := utils.Truncate(finalContent, 120)
logger.InfoCF("agent", fmt.Sprintf("Response: %s", responsePreview),
- map[string]interface{}{
+ map[string]any{
+ "agent_id": agent.ID,
"session_key": opts.SessionKey,
"iterations": iteration,
"final_length": len(finalContent),
@@ -408,109 +471,199 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, opts processOptions) (str
}
// runLLMIteration executes the LLM call loop with tool handling.
-// Returns the final content, iteration count, and any error.
-func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.Message, opts processOptions) (string, int, error) {
+func (al *AgentLoop) runLLMIteration(
+ ctx context.Context,
+ agent *AgentInstance,
+ messages []providers.Message,
+ opts processOptions,
+) (string, int, error) {
iteration := 0
var finalContent string
- for iteration < al.maxIterations {
+ for iteration < agent.MaxIterations {
iteration++
logger.DebugCF("agent", "LLM iteration",
- map[string]interface{}{
+ map[string]any{
+ "agent_id": agent.ID,
"iteration": iteration,
- "max": al.maxIterations,
+ "max": agent.MaxIterations,
})
// Build tool definitions
- providerToolDefs := al.tools.ToProviderDefs()
+ providerToolDefs := agent.Tools.ToProviderDefs()
// Log LLM request details
logger.DebugCF("agent", "LLM request",
- map[string]interface{}{
+ map[string]any{
+ "agent_id": agent.ID,
"iteration": iteration,
- "model": al.model,
+ "model": agent.Model,
"messages_count": len(messages),
"tools_count": len(providerToolDefs),
- "max_tokens": 8192,
- "temperature": 0.7,
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
"system_prompt_len": len(messages[0].Content),
})
// Log full messages (detailed)
logger.DebugCF("agent", "Full LLM request",
- map[string]interface{}{
+ map[string]any{
"iteration": iteration,
"messages_json": formatMessagesForLog(messages),
"tools_json": formatToolsForLog(providerToolDefs),
})
- // Call LLM
- response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
- "max_tokens": 8192,
- "temperature": 0.7,
- })
+ // Call LLM with fallback chain if candidates are configured.
+ var response *providers.LLMResponse
+ var err error
+
+ callLLM := func() (*providers.LLMResponse, error) {
+ if len(agent.Candidates) > 1 && al.fallback != nil {
+ fbResult, fbErr := al.fallback.Execute(ctx, agent.Candidates,
+ func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) {
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ })
+ },
+ )
+ if fbErr != nil {
+ return nil, fbErr
+ }
+ if fbResult.Provider != "" && len(fbResult.Attempts) > 0 {
+ logger.InfoCF("agent", fmt.Sprintf("Fallback: succeeded with %s/%s after %d attempts",
+ fbResult.Provider, fbResult.Model, len(fbResult.Attempts)+1),
+ map[string]any{"agent_id": agent.ID, "iteration": iteration})
+ }
+ return fbResult.Response, nil
+ }
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
+ })
+ }
+
+ // Retry loop for context/token errors
+ maxRetries := 2
+ for retry := 0; retry <= maxRetries; retry++ {
+ response, err = callLLM()
+ if err == nil {
+ break
+ }
+
+ errMsg := strings.ToLower(err.Error())
+ isContextError := strings.Contains(errMsg, "token") ||
+ strings.Contains(errMsg, "context") ||
+ strings.Contains(errMsg, "invalidparameter") ||
+ strings.Contains(errMsg, "length")
+
+ if isContextError && retry < maxRetries {
+ logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
+ "error": err.Error(),
+ "retry": retry,
+ })
+
+ if retry == 0 && !constants.IsInternalChannel(opts.Channel) {
+ al.bus.PublishOutbound(bus.OutboundMessage{
+ Channel: opts.Channel,
+ ChatID: opts.ChatID,
+ Content: "Context window exceeded. Compressing history and retrying...",
+ })
+ }
+
+ al.forceCompression(agent, opts.SessionKey)
+ newHistory := agent.Sessions.GetHistory(opts.SessionKey)
+ newSummary := agent.Sessions.GetSummary(opts.SessionKey)
+ messages = agent.ContextBuilder.BuildMessages(
+ newHistory, newSummary, "",
+ nil, opts.Channel, opts.ChatID,
+ )
+ continue
+ }
+ break
+ }
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
- map[string]interface{}{
+ map[string]any{
+ "agent_id": agent.ID,
"iteration": iteration,
"error": err.Error(),
})
- return "", iteration, fmt.Errorf("LLM call failed: %w", err)
+ return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
}
// Check if no tool calls - we're done
if len(response.ToolCalls) == 0 {
finalContent = response.Content
logger.InfoCF("agent", "LLM response without tool calls (direct answer)",
- map[string]interface{}{
+ map[string]any{
+ "agent_id": agent.ID,
"iteration": iteration,
"content_chars": len(finalContent),
})
break
}
- // Log tool calls
- toolNames := make([]string, 0, len(response.ToolCalls))
+ normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls))
for _, tc := range response.ToolCalls {
+ normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc))
+ }
+
+ // Log tool calls
+ toolNames := make([]string, 0, len(normalizedToolCalls))
+ for _, tc := range normalizedToolCalls {
toolNames = append(toolNames, tc.Name)
}
logger.InfoCF("agent", "LLM requested tool calls",
- map[string]interface{}{
+ map[string]any{
+ "agent_id": agent.ID,
"tools": toolNames,
- "count": len(response.ToolCalls),
+ "count": len(normalizedToolCalls),
"iteration": iteration,
})
// Build assistant message with tool calls
assistantMsg := providers.Message{
- Role: "assistant",
- Content: response.Content,
+ Role: "assistant",
+ Content: response.Content,
+ ReasoningContent: response.ReasoningContent,
}
- for _, tc := range response.ToolCalls {
+ for _, tc := range normalizedToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
+ // Copy ExtraContent to ensure thought_signature is persisted for Gemini 3
+ extraContent := tc.ExtraContent
+ thoughtSignature := ""
+ if tc.Function != nil {
+ thoughtSignature = tc.Function.ThoughtSignature
+ }
+
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID,
Type: "function",
+ Name: tc.Name,
Function: &providers.FunctionCall{
- Name: tc.Name,
- Arguments: string(argumentsJSON),
+ Name: tc.Name,
+ Arguments: string(argumentsJSON),
+ ThoughtSignature: thoughtSignature,
},
+ ExtraContent: extraContent,
+ ThoughtSignature: thoughtSignature,
})
}
messages = append(messages, assistantMsg)
// Save assistant message with tool calls to session
- al.sessions.AddFullMessage(opts.SessionKey, assistantMsg)
+ agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Execute tool calls
- for _, tc := range response.ToolCalls {
- // Log tool call with arguments preview
+ for _, tc := range normalizedToolCalls {
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
- map[string]interface{}{
+ map[string]any{
+ "agent_id": agent.ID,
"tool": tc.Name,
"iteration": iteration,
})
@@ -524,14 +677,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
// The agent will handle user notification via processSystemMessage
if !result.Silent && result.ForUser != "" {
logger.InfoCF("agent", "Async tool completed, agent will handle notification",
- map[string]interface{}{
+ map[string]any{
"tool": tc.Name,
"content_len": len(result.ForUser),
})
}
}
- toolResult := al.tools.ExecuteWithContext(ctx, tc.Name, tc.Arguments, opts.Channel, opts.ChatID, asyncCallback)
+ toolResult := agent.Tools.ExecuteWithContext(
+ ctx,
+ tc.Name,
+ tc.Arguments,
+ opts.Channel,
+ opts.ChatID,
+ asyncCallback,
+ )
// Send ForUser content to user immediately if not Silent
if !toolResult.Silent && toolResult.ForUser != "" && opts.SendResponse {
@@ -541,7 +701,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
Content: toolResult.ForUser,
})
logger.DebugCF("agent", "Sent tool result to user",
- map[string]interface{}{
+ map[string]any{
"tool": tc.Name,
"content_len": len(toolResult.ForUser),
})
@@ -561,7 +721,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
messages = append(messages, toolResultMsg)
// Save tool result message to session
- al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
+ agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
}
}
@@ -569,19 +729,19 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
}
// updateToolContexts updates the context for tools that need channel/chatID info.
-func (al *AgentLoop) updateToolContexts(channel, chatID string) {
+func (al *AgentLoop) updateToolContexts(agent *AgentInstance, channel, chatID string) {
// Use ContextualTool interface instead of type assertions
- if tool, ok := al.tools.Get("message"); ok {
+ if tool, ok := agent.Tools.Get("message"); ok {
if mt, ok := tool.(tools.ContextualTool); ok {
mt.SetContext(channel, chatID)
}
}
- if tool, ok := al.tools.Get("spawn"); ok {
+ if tool, ok := agent.Tools.Get("spawn"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
}
- if tool, ok := al.tools.Get("subagent"); ok {
+ if tool, ok := agent.Tools.Get("subagent"); ok {
if st, ok := tool.(tools.ContextualTool); ok {
st.SetContext(channel, chatID)
}
@@ -589,34 +749,106 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string) {
}
// maybeSummarize triggers summarization if the session history exceeds thresholds.
-func (al *AgentLoop) maybeSummarize(sessionKey string) {
- newHistory := al.sessions.GetHistory(sessionKey)
+func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) {
+ newHistory := agent.Sessions.GetHistory(sessionKey)
tokenEstimate := al.estimateTokens(newHistory)
- threshold := al.contextWindow * 75 / 100
+ threshold := agent.ContextWindow * 75 / 100
if len(newHistory) > 20 || tokenEstimate > threshold {
- if _, loading := al.summarizing.LoadOrStore(sessionKey, true); !loading {
+ summarizeKey := agent.ID + ":" + sessionKey
+ if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading {
go func() {
- defer al.summarizing.Delete(sessionKey)
- al.summarizeSession(sessionKey)
+ defer al.summarizing.Delete(summarizeKey)
+ if !constants.IsInternalChannel(channel) {
+ al.bus.PublishOutbound(bus.OutboundMessage{
+ Channel: channel,
+ ChatID: chatID,
+ Content: "Memory threshold reached. Optimizing conversation history...",
+ })
+ }
+ 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)
+
+ // 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),
+ })
+}
+
// GetStartupInfo returns information about loaded tools and skills for logging.
-func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
- info := make(map[string]interface{})
+func (al *AgentLoop) GetStartupInfo() map[string]any {
+ info := make(map[string]any)
+
+ agent := al.registry.GetDefaultAgent()
+ if agent == nil {
+ return info
+ }
// Tools info
- tools := al.tools.List()
- info["tools"] = map[string]interface{}{
- "count": len(tools),
- "names": tools,
+ toolsList := agent.Tools.List()
+ info["tools"] = map[string]any{
+ "count": len(toolsList),
+ "names": toolsList,
}
// Skills info
- info["skills"] = al.contextBuilder.GetSkillsInfo()
+ info["skills"] = agent.ContextBuilder.GetSkillsInfo()
+
+ // Agents info
+ info["agents"] = map[string]any{
+ "count": len(al.registry.ListAgentIDs()),
+ "ids": al.registry.ListAgentIDs(),
+ }
return info
}
@@ -627,58 +859,58 @@ func formatMessagesForLog(messages []providers.Message) string {
return "[]"
}
- var result string
- result += "[\n"
+ var sb strings.Builder
+ sb.WriteString("[\n")
for i, msg := range messages {
- result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role)
- if msg.ToolCalls != nil && len(msg.ToolCalls) > 0 {
- result += " ToolCalls:\n"
+ fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
+ if len(msg.ToolCalls) > 0 {
+ sb.WriteString(" ToolCalls:\n")
for _, tc := range msg.ToolCalls {
- result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
+ fmt.Fprintf(&sb, " - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
if tc.Function != nil {
- result += fmt.Sprintf(" Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200))
+ fmt.Fprintf(&sb, " Arguments: %s\n", utils.Truncate(tc.Function.Arguments, 200))
}
}
}
if msg.Content != "" {
content := utils.Truncate(msg.Content, 200)
- result += fmt.Sprintf(" Content: %s\n", content)
+ fmt.Fprintf(&sb, " Content: %s\n", content)
}
if msg.ToolCallID != "" {
- result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID)
+ fmt.Fprintf(&sb, " ToolCallID: %s\n", msg.ToolCallID)
}
- result += "\n"
+ sb.WriteString("\n")
}
- result += "]"
- return result
+ sb.WriteString("]")
+ return sb.String()
}
// formatToolsForLog formats tool definitions for logging
-func formatToolsForLog(tools []providers.ToolDefinition) string {
- if len(tools) == 0 {
+func formatToolsForLog(toolDefs []providers.ToolDefinition) string {
+ if len(toolDefs) == 0 {
return "[]"
}
- var result string
- result += "[\n"
- for i, tool := range tools {
- result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
- result += fmt.Sprintf(" Description: %s\n", tool.Function.Description)
+ 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 {
- result += fmt.Sprintf(" Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200))
+ fmt.Fprintf(&sb, " Parameters: %s\n", utils.Truncate(fmt.Sprintf("%v", tool.Function.Parameters), 200))
}
}
- result += "]"
- return result
+ sb.WriteString("]")
+ return sb.String()
}
// summarizeSession summarizes the conversation history for a session.
-func (al *AgentLoop) summarizeSession(sessionKey string) {
+func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
- history := al.sessions.GetHistory(sessionKey)
- summary := al.sessions.GetSummary(sessionKey)
+ history := agent.Sessions.GetHistory(sessionKey)
+ summary := agent.Sessions.GetSummary(sessionKey)
// Keep last 4 messages for continuity
if len(history) <= 4 {
@@ -688,8 +920,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
toSummarize := history[:len(history)-4]
// Oversized Message Guard
- // Skip messages larger than 50% of context window to prevent summarizer overflow
- maxMessageTokens := al.contextWindow / 2
+ maxMessageTokens := agent.ContextWindow / 2
validMessages := make([]providers.Message, 0)
omitted := false
@@ -697,8 +928,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
if m.Role != "user" && m.Role != "assistant" {
continue
}
- // Estimate tokens for this message
- msgTokens := len(m.Content) / 4
+ msgTokens := len(m.Content) / 2
if msgTokens > maxMessageTokens {
omitted = true
continue
@@ -711,29 +941,37 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
}
// Multi-Part Summarization
- // Split into two parts if history is significant
var finalSummary string
if len(validMessages) > 10 {
mid := len(validMessages) / 2
part1 := validMessages[:mid]
part2 := validMessages[mid:]
- s1, _ := al.summarizeBatch(ctx, part1, "")
- s2, _ := al.summarizeBatch(ctx, part2, "")
+ s1, _ := al.summarizeBatch(ctx, agent, part1, "")
+ s2, _ := al.summarizeBatch(ctx, agent, part2, "")
- // Merge them
- mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
- resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{
- "max_tokens": 1024,
- "temperature": 0.3,
- })
+ 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,
+ },
+ )
if err == nil {
finalSummary = resp.Content
} else {
finalSummary = s1 + " " + s2
}
} else {
- finalSummary, _ = al.summarizeBatch(ctx, validMessages, summary)
+ finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary)
}
if omitted && finalSummary != "" {
@@ -741,27 +979,42 @@ func (al *AgentLoop) summarizeSession(sessionKey string) {
}
if finalSummary != "" {
- al.sessions.SetSummary(sessionKey, finalSummary)
- al.sessions.TruncateHistory(sessionKey, 4)
- al.sessions.Save(sessionKey)
+ 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, batch []providers.Message, existingSummary string) (string, error) {
- prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n"
+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 existingSummary != "" {
- prompt += "Existing context: " + existingSummary + "\n"
+ sb.WriteString("Existing context: ")
+ sb.WriteString(existingSummary)
+ sb.WriteString("\n")
}
- prompt += "\nCONVERSATION:\n"
+ sb.WriteString("\nCONVERSATION:\n")
for _, m := range batch {
- prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
+ fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content)
}
+ prompt := sb.String()
- response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{
- "max_tokens": 1024,
- "temperature": 0.3,
- })
+ response, err := agent.Provider.Chat(
+ ctx,
+ []providers.Message{{Role: "user", Content: prompt}},
+ nil,
+ agent.Model,
+ map[string]any{
+ "max_tokens": 1024,
+ "temperature": 0.3,
+ },
+ )
if err != nil {
return "", err
}
@@ -769,13 +1022,130 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
}
// estimateTokens estimates the number of tokens in a message list.
-// Uses rune count instead of byte length so that CJK and other multi-byte
-// characters are not over-counted (a Chinese character is 3 bytes but roughly
-// one token).
+// 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 {
- total := 0
+ totalChars := 0
for _, m := range messages {
- total += utf8.RuneCountInString(m.Content) / 3
+ totalChars += utf8.RuneCountInString(m.Content)
}
- return total
+ // 2.5 chars per token = totalChars * 2 / 5
+ return totalChars * 2 / 5
+}
+
+func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) {
+ content := strings.TrimSpace(msg.Content)
+ if !strings.HasPrefix(content, "/") {
+ return "", false
+ }
+
+ parts := strings.Fields(content)
+ if len(parts) == 0 {
+ return "", false
+ }
+
+ cmd := parts[0]
+ args := parts[1:]
+
+ switch cmd {
+ case "/show":
+ if len(args) < 1 {
+ return "Usage: /show [model|channel|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 ", 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
+ }
+ }
+
+ return "", false
+}
+
+// extractPeer extracts the routing peer from inbound message metadata.
+func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
+ peerKind := msg.Metadata["peer_kind"]
+ if peerKind == "" {
+ return nil
+ }
+ peerID := msg.Metadata["peer_id"]
+ if peerID == "" {
+ if peerKind == "direct" {
+ peerID = msg.SenderID
+ } else {
+ peerID = msg.ChatID
+ }
+ }
+ return &routing.RoutePeer{Kind: peerKind, 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}
}
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index c18220258..4414398b1 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -2,6 +2,7 @@ package agent
import (
"context"
+ "fmt"
"os"
"path/filepath"
"testing"
@@ -13,20 +14,6 @@ import (
"github.com/sipeed/picoclaw/pkg/tools"
)
-// mockProvider is a simple mock LLM provider for testing
-type mockProvider struct{}
-
-func (m *mockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
- return &providers.LLMResponse{
- Content: "Mock response",
- ToolCalls: []providers.ToolCall{},
- }, nil
-}
-
-func (m *mockProvider) GetDefaultModel() string {
- return "mock-model"
-}
-
func TestRecordLastChannel(t *testing.T) {
// Create temp workspace
tmpDir, err := os.MkdirTemp("", "agent-test-*")
@@ -184,7 +171,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
// Verify tool is registered by checking it doesn't panic on GetStartupInfo
// (actual tool retrieval is tested in tools package tests)
info := al.GetStartupInfo()
- toolsInfo := info["tools"].(map[string]interface{})
+ toolsInfo := info["tools"].(map[string]any)
toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list
@@ -259,7 +246,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
al.RegisterTool(testTool)
info := al.GetStartupInfo()
- toolsInfo := info["tools"].(map[string]interface{})
+ toolsInfo := info["tools"].(map[string]any)
toolsList := toolsInfo["names"].([]string)
// Check that our custom tool name is in the list
@@ -306,7 +293,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
t.Fatal("Expected 'tools' key in startup info")
}
- toolsMap, ok := toolsInfo.(map[string]interface{})
+ toolsMap, ok := toolsInfo.(map[string]any)
if !ok {
t.Fatal("Expected 'tools' to be a map")
}
@@ -362,7 +349,13 @@ type simpleMockProvider struct {
response string
}
-func (m *simpleMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+func (m *simpleMockProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
return &providers.LLMResponse{
Content: m.response,
ToolCalls: []providers.ToolCall{},
@@ -384,14 +377,14 @@ func (m *mockCustomTool) Description() string {
return "Mock custom tool for testing"
}
-func (m *mockCustomTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (m *mockCustomTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{},
+ "properties": map[string]any{},
}
}
-func (m *mockCustomTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
+func (m *mockCustomTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.SilentResult("Custom tool executed")
}
@@ -409,14 +402,14 @@ func (m *mockContextualTool) Description() string {
return "Mock contextual tool"
}
-func (m *mockContextualTool) Parameters() map[string]interface{} {
- return map[string]interface{}{
+func (m *mockContextualTool) Parameters() map[string]any {
+ return map[string]any{
"type": "object",
- "properties": map[string]interface{}{},
+ "properties": map[string]any{},
}
}
-func (m *mockContextualTool) Execute(ctx context.Context, args map[string]interface{}) *tools.ToolResult {
+func (m *mockContextualTool) Execute(ctx context.Context, args map[string]any) *tools.ToolResult {
return tools.SilentResult("Contextual tool executed")
}
@@ -527,3 +520,114 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
t.Errorf("Expected 'Command output: hello world', got: %s", response)
}
}
+
+// failFirstMockProvider fails on the first N calls with a specific error
+type failFirstMockProvider struct {
+ failures int
+ currentCall int
+ failError error
+ successResp string
+}
+
+func (m *failFirstMockProvider) Chat(
+ ctx context.Context,
+ messages []providers.Message,
+ tools []providers.ToolDefinition,
+ model string,
+ opts map[string]any,
+) (*providers.LLMResponse, error) {
+ m.currentCall++
+ if m.currentCall <= m.failures {
+ return nil, m.failError
+ }
+ return &providers.LLMResponse{
+ Content: m.successResp,
+ ToolCalls: []providers.ToolCall{},
+ }, nil
+}
+
+func (m *failFirstMockProvider) GetDefaultModel() string {
+ return "mock-fail-model"
+}
+
+// TestAgentLoop_ContextExhaustionRetry verify that the agent retries on context errors
+func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "agent-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: "test-model",
+ MaxTokens: 4096,
+ MaxToolIterations: 10,
+ },
+ },
+ }
+
+ msgBus := bus.NewMessageBus()
+
+ // Create a provider that fails once with a context error
+ contextErr := fmt.Errorf("InvalidParameter: Total tokens of image and text exceed max message tokens")
+ provider := &failFirstMockProvider{
+ failures: 1,
+ failError: contextErr,
+ successResp: "Recovered from context error",
+ }
+
+ al := NewAgentLoop(cfg, msgBus, provider)
+
+ // Inject some history to simulate a full context
+ sessionKey := "test-session-context"
+ // Create dummy history
+ history := []providers.Message{
+ {Role: "system", Content: "System prompt"},
+ {Role: "user", Content: "Old message 1"},
+ {Role: "assistant", Content: "Old response 1"},
+ {Role: "user", Content: "Old message 2"},
+ {Role: "assistant", Content: "Old response 2"},
+ {Role: "user", Content: "Trigger message"},
+ }
+ defaultAgent := al.registry.GetDefaultAgent()
+ if defaultAgent == nil {
+ t.Fatal("No default agent found")
+ }
+ defaultAgent.Sessions.SetHistory(sessionKey, history)
+
+ // Call ProcessDirectWithChannel
+ // Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
+ response, err := al.ProcessDirectWithChannel(
+ context.Background(),
+ "Trigger message",
+ sessionKey,
+ "test",
+ "test-chat",
+ )
+ if err != nil {
+ t.Fatalf("Expected success after retry, got error: %v", err)
+ }
+
+ if response != "Recovered from context error" {
+ t.Errorf("Expected 'Recovered from context error', got '%s'", response)
+ }
+
+ // We expect 2 calls: 1st failed, 2nd succeeded
+ if provider.currentCall != 2 {
+ t.Errorf("Expected 2 calls (1 fail + 1 success), got %d", provider.currentCall)
+ }
+
+ // Check final history length
+ finalHistory := defaultAgent.Sessions.GetHistory(sessionKey)
+ // We verify that the history has been modified (compressed)
+ // Original length: 6
+ // Expected behavior: compression drops ~50% of history (mid slice)
+ // We can assert that the length is NOT what it would be without compression.
+ // Without compression: 6 + 1 (new user msg) + 1 (assistant msg) = 8
+ if len(finalHistory) >= 8 {
+ t.Errorf("Expected history to be compressed (len < 8), got %d", len(finalHistory))
+ }
+}
diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go
index 3f6896f91..dd5f4441c 100644
--- a/pkg/agent/memory.go
+++ b/pkg/agent/memory.go
@@ -10,6 +10,7 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
)
@@ -29,7 +30,7 @@ func NewMemoryStore(workspace string) *MemoryStore {
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
// Ensure memory directory exists
- os.MkdirAll(memoryDir, 0755)
+ os.MkdirAll(memoryDir, 0o755)
return &MemoryStore{
workspace: workspace,
@@ -57,7 +58,7 @@ func (ms *MemoryStore) ReadLongTerm() string {
// WriteLongTerm writes content to the long-term memory file (MEMORY.md).
func (ms *MemoryStore) WriteLongTerm(content string) error {
- return os.WriteFile(ms.memoryFile, []byte(content), 0644)
+ return os.WriteFile(ms.memoryFile, []byte(content), 0o644)
}
// ReadToday reads today's daily note.
@@ -77,7 +78,7 @@ func (ms *MemoryStore) AppendToday(content string) error {
// Ensure month directory exists
monthDir := filepath.Dir(todayFile)
- os.MkdirAll(monthDir, 0755)
+ os.MkdirAll(monthDir, 0o755)
var existingContent string
if data, err := os.ReadFile(todayFile); err == nil {
@@ -94,13 +95,14 @@ func (ms *MemoryStore) AppendToday(content string) error {
newContent = existingContent + "\n" + content
}
- return os.WriteFile(todayFile, []byte(newContent), 0644)
+ return os.WriteFile(todayFile, []byte(newContent), 0o644)
}
// GetRecentDailyNotes returns daily notes from the last N days.
// Contents are joined with "---" separator.
func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
- var notes []string
+ var sb strings.Builder
+ first := true
for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -i)
@@ -109,53 +111,41 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
if data, err := os.ReadFile(filePath); err == nil {
- notes = append(notes, string(data))
+ if !first {
+ sb.WriteString("\n\n---\n\n")
+ }
+ sb.Write(data)
+ first = false
}
}
- if len(notes) == 0 {
- return ""
- }
-
- // Join with separator
- var result string
- for i, note := range notes {
- if i > 0 {
- result += "\n\n---\n\n"
- }
- result += note
- }
- return result
+ return sb.String()
}
// GetMemoryContext returns formatted memory context for the agent prompt.
// Includes long-term memory and recent daily notes.
func (ms *MemoryStore) GetMemoryContext() string {
- var parts []string
-
- // Long-term memory
longTerm := ms.ReadLongTerm()
- if longTerm != "" {
- parts = append(parts, "## Long-term Memory\n\n"+longTerm)
- }
-
- // Recent daily notes (last 3 days)
recentNotes := ms.GetRecentDailyNotes(3)
- if recentNotes != "" {
- parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
- }
- if len(parts) == 0 {
+ if longTerm == "" && recentNotes == "" {
return ""
}
- // Join parts with separator
- var result string
- for i, part := range parts {
- if i > 0 {
- result += "\n\n---\n\n"
- }
- result += part
+ var sb strings.Builder
+
+ if longTerm != "" {
+ sb.WriteString("## Long-term Memory\n\n")
+ sb.WriteString(longTerm)
}
- return fmt.Sprintf("# Memory\n\n%s", result)
+
+ if recentNotes != "" {
+ if longTerm != "" {
+ sb.WriteString("\n\n---\n\n")
+ }
+ sb.WriteString("## Recent Daily Notes\n\n")
+ sb.WriteString(recentNotes)
+ }
+
+ return sb.String()
}
diff --git a/pkg/agent/mock_provider_test.go b/pkg/agent/mock_provider_test.go
new file mode 100644
index 000000000..4962810dc
--- /dev/null
+++ b/pkg/agent/mock_provider_test.go
@@ -0,0 +1,26 @@
+package agent
+
+import (
+ "context"
+
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+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",
+ ToolCalls: []providers.ToolCall{},
+ }, nil
+}
+
+func (m *mockProvider) GetDefaultModel() string {
+ return "mock-model"
+}
diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go
new file mode 100644
index 000000000..77b846832
--- /dev/null
+++ b/pkg/agent/registry.go
@@ -0,0 +1,114 @@
+package agent
+
+import (
+ "sync"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/providers"
+ "github.com/sipeed/picoclaw/pkg/routing"
+)
+
+// AgentRegistry manages multiple agent instances and routes messages to them.
+type AgentRegistry struct {
+ agents map[string]*AgentInstance
+ resolver *routing.RouteResolver
+ 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),
+ resolver: routing.NewRouteResolver(cfg),
+ }
+
+ agentConfigs := cfg.Agents.List
+ if len(agentConfigs) == 0 {
+ implicitAgent := &config.AgentConfig{
+ 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,
+ "workspace": instance.Workspace,
+ "model": instance.Model,
+ })
+ }
+ }
+
+ return registry
+}
+
+// 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
+}
+
+// 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
+}
diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go
new file mode 100644
index 000000000..518bb441f
--- /dev/null
+++ b/pkg/agent/registry_test.go
@@ -0,0 +1,205 @@
+package agent
+
+import (
+ "context"
+ "testing"
+
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/providers"
+)
+
+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
+}
+
+func (m *mockRegistryProvider) GetDefaultModel() string {
+ return "mock-model"
+}
+
+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,
+ MaxToolIterations: 10,
+ },
+ List: agents,
+ },
+ }
+}
+
+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)
+ }
+}
+
+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")
+ }
+}
+
+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)
+ }
+}
+
+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")
+ }
+}
+
+func TestAgentRegistry_CanSpawnSubagent(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {
+ 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)")
+ }
+}
+
+func TestAgentRegistry_CanSpawnSubagent_Wildcard(t *testing.T) {
+ cfg := testCfg([]config.AgentConfig{
+ {
+ 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")
+ }
+}
+
+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)
+ }
+}
+
+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))
+ }
+}
+
+func TestAgentInstance_FallbackExplicitEmpty(t *testing.T) {
+ model := &config.AgentModelConfig{
+ 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)
+ }
+}
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index 1a6589641..cf8c1c9c4 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -1,6 +1,7 @@
package auth
import (
+ "bufio"
"context"
"crypto/rand"
"encoding/base64"
@@ -11,6 +12,7 @@ import (
"net"
"net/http"
"net/url"
+ "os"
"os/exec"
"runtime"
"strconv"
@@ -19,11 +21,13 @@ import (
)
type OAuthProviderConfig struct {
- Issuer string
- ClientID string
- Scopes string
- Originator string
- Port int
+ Issuer string
+ ClientID string
+ ClientSecret string // Required for Google OAuth (confidential client)
+ TokenURL string // Override token endpoint (Google uses a different URL than issuer)
+ Scopes string
+ Originator string
+ Port int
}
func OpenAIOAuthConfig() OAuthProviderConfig {
@@ -36,6 +40,32 @@ func OpenAIOAuthConfig() OAuthProviderConfig {
}
}
+// GoogleAntigravityOAuthConfig returns the OAuth configuration for Google Cloud Code Assist (Antigravity).
+// Client credentials are the same ones used by OpenCode/pi-ai for Cloud Code Assist access.
+func GoogleAntigravityOAuthConfig() OAuthProviderConfig {
+ // These are the same client credentials used by the OpenCode antigravity plugin.
+ clientID := decodeBase64(
+ "MTA3MTAwNjA2MDU5MS10bWhzc2luMmgyMWxjcmUyMzV2dG9sb2poNGc0MDNlcC5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbQ==",
+ )
+ clientSecret := decodeBase64("R09DU1BYLUs1OEZXUjQ4NkxkTEoxbUxCOHNYQzR6NnFEQWY=")
+ return OAuthProviderConfig{
+ Issuer: "https://accounts.google.com/o/oauth2/v2",
+ TokenURL: "https://oauth2.googleapis.com/token",
+ ClientID: clientID,
+ ClientSecret: clientSecret,
+ Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs",
+ Port: 51121,
+ }
+}
+
+func decodeBase64(s string) string {
+ data, err := base64.StdEncoding.DecodeString(s)
+ if err != nil {
+ return s
+ }
+ return string(data)
+}
+
func generateState() (string, error) {
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
@@ -101,8 +131,22 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL)
}
- fmt.Println("If you're running in a headless environment, use: picoclaw auth login --provider openai --device-code")
- fmt.Println("Waiting for authentication in browser...")
+ fmt.Printf(
+ "Wait! If you are in a headless environment (like Coolify/VPS) and cannot reach localhost:%d,\n",
+ cfg.Port,
+ )
+ fmt.Println(
+ "please complete the login in your local browser and then PASTE the final redirect URL (or just the code) here.",
+ )
+ fmt.Println("Waiting for authentication (browser or manual paste)...")
+
+ // Start manual input in a goroutine
+ manualCh := make(chan string)
+ go func() {
+ reader := bufio.NewReader(os.Stdin)
+ input, _ := reader.ReadString('\n')
+ manualCh <- strings.TrimSpace(input)
+ }()
select {
case result := <-resultCh:
@@ -110,6 +154,22 @@ func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) {
return nil, result.err
}
return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI)
+ case manualInput := <-manualCh:
+ if manualInput == "" {
+ return nil, fmt.Errorf("manual input cancelled")
+ }
+ // Extract code from URL if it's a full URL
+ code := manualInput
+ if strings.Contains(manualInput, "?") {
+ u, err := url.Parse(manualInput)
+ if err == nil {
+ code = u.Query().Get("code")
+ }
+ }
+ if code == "" {
+ return nil, fmt.Errorf("could not find authorization code in input")
+ }
+ return exchangeCodeForTokens(cfg, code, pkce.CodeVerifier, redirectURI)
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("authentication timed out after 5 minutes")
}
@@ -200,8 +260,11 @@ func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) {
deviceResp.Interval = 5
}
- fmt.Printf("\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
- cfg.Issuer, deviceResp.UserCode)
+ fmt.Printf(
+ "\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n",
+ cfg.Issuer,
+ deviceResp.UserCode,
+ )
deadline := time.After(15 * time.Minute)
ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second)
@@ -269,8 +332,16 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
"refresh_token": {cred.RefreshToken},
"scope": {"openid profile email"},
}
+ if cfg.ClientSecret != "" {
+ data.Set("client_secret", cfg.ClientSecret)
+ }
- resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
+ tokenURL := cfg.Issuer + "/oauth/token"
+ if cfg.TokenURL != "" {
+ tokenURL = cfg.TokenURL
+ }
+
+ resp, err := http.PostForm(tokenURL, data)
if err != nil {
return nil, fmt.Errorf("refreshing token: %w", err)
}
@@ -281,7 +352,23 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
return nil, fmt.Errorf("token refresh failed: %s", string(body))
}
- return parseTokenResponse(body, cred.Provider)
+ refreshed, err := parseTokenResponse(body, cred.Provider)
+ if err != nil {
+ return nil, err
+ }
+ if refreshed.RefreshToken == "" {
+ refreshed.RefreshToken = cred.RefreshToken
+ }
+ if refreshed.AccountID == "" {
+ refreshed.AccountID = cred.AccountID
+ }
+ if cred.Email != "" && refreshed.Email == "" {
+ refreshed.Email = cred.Email
+ }
+ if cred.ProjectID != "" && refreshed.ProjectID == "" {
+ refreshed.ProjectID = cred.ProjectID
+ }
+ return refreshed, nil
}
func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
@@ -290,18 +377,35 @@ func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
params := url.Values{
- "response_type": {"code"},
- "client_id": {cfg.ClientID},
- "redirect_uri": {redirectURI},
- "scope": {cfg.Scopes},
- "code_challenge": {pkce.CodeChallenge},
- "code_challenge_method": {"S256"},
- "id_token_add_organizations": {"true"},
- "codex_cli_simplified_flow": {"true"},
- "state": {state},
+ "response_type": {"code"},
+ "client_id": {cfg.ClientID},
+ "redirect_uri": {redirectURI},
+ "scope": {cfg.Scopes},
+ "code_challenge": {pkce.CodeChallenge},
+ "code_challenge_method": {"S256"},
+ "state": {state},
}
- if cfg.Originator != "" {
- params.Set("originator", cfg.Originator)
+
+ isGoogle := strings.Contains(strings.ToLower(cfg.Issuer), "accounts.google.com")
+ if isGoogle {
+ // Google OAuth requires these for refresh token support
+ params.Set("access_type", "offline")
+ params.Set("prompt", "consent")
+ } else {
+ // OpenAI-specific parameters
+ params.Set("id_token_add_organizations", "true")
+ params.Set("codex_cli_simplified_flow", "true")
+ if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
+ params.Set("originator", "picoclaw")
+ }
+ if cfg.Originator != "" {
+ params.Set("originator", cfg.Originator)
+ }
+ }
+
+ // Google uses /auth path, OpenAI uses /oauth/authorize
+ if isGoogle {
+ return cfg.Issuer + "/auth?" + params.Encode()
}
return cfg.Issuer + "/oauth/authorize?" + params.Encode()
}
@@ -314,8 +418,22 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
"client_id": {cfg.ClientID},
"code_verifier": {codeVerifier},
}
+ if cfg.ClientSecret != "" {
+ data.Set("client_secret", cfg.ClientSecret)
+ }
- resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data)
+ tokenURL := cfg.Issuer + "/oauth/token"
+ if cfg.TokenURL != "" {
+ tokenURL = cfg.TokenURL
+ }
+
+ // Determine provider name from config
+ provider := "openai"
+ if cfg.TokenURL != "" && strings.Contains(cfg.TokenURL, "googleapis.com") {
+ provider = "google-antigravity"
+ }
+
+ resp, err := http.PostForm(tokenURL, data)
if err != nil {
return nil, fmt.Errorf("exchanging code for tokens: %w", err)
}
@@ -326,7 +444,7 @@ func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirect
return nil, fmt.Errorf("token exchange failed: %s", string(body))
}
- return parseTokenResponse(body, "openai")
+ return parseTokenResponse(body, provider)
}
func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
@@ -357,7 +475,9 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
AuthMethod: "oauth",
}
- if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
+ if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
+ cred.AccountID = accountID
+ } else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
cred.AccountID = accountID
} else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
// Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims.
@@ -367,12 +487,45 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
return cred, nil
}
-func extractAccountID(accessToken string) string {
- parts := strings.Split(accessToken, ".")
- if len(parts) < 2 {
+func extractAccountID(token string) string {
+ claims, err := parseJWTClaims(token)
+ if err != nil {
return ""
}
+ if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" {
+ return accountID
+ }
+
+ if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" {
+ return accountID
+ }
+
+ if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]any); ok {
+ if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" {
+ return accountID
+ }
+ }
+
+ if orgs, ok := claims["organizations"].([]any); ok {
+ for _, org := range orgs {
+ if orgMap, ok := org.(map[string]any); ok {
+ if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
+ return accountID
+ }
+ }
+ }
+ }
+
+ return ""
+}
+
+func parseJWTClaims(token string) (map[string]any, error) {
+ parts := strings.Split(token, ".")
+ if len(parts) < 2 {
+ return nil, fmt.Errorf("token is not a JWT")
+ }
+
payload := parts[1]
switch len(payload) % 4 {
case 2:
@@ -383,21 +536,15 @@ func extractAccountID(accessToken string) string {
decoded, err := base64URLDecode(payload)
if err != nil {
- return ""
+ return nil, err
}
- var claims map[string]interface{}
+ var claims map[string]any
if err := json.Unmarshal(decoded, &claims); err != nil {
- return ""
+ return nil, err
}
- if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
- if accountID, ok := authClaim["chatgpt_account_id"].(string); ok {
- return accountID
- }
- }
-
- return ""
+ return claims, nil
}
func base64URLDecode(s string) ([]byte, error) {
diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go
index 0d2ccc9a5..0cb589069 100644
--- a/pkg/auth/oauth_test.go
+++ b/pkg/auth/oauth_test.go
@@ -5,10 +5,23 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
"testing"
)
+func makeJWTForClaims(t *testing.T, claims map[string]any) string {
+ t.Helper()
+
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
+ payloadJSON, err := json.Marshal(claims)
+ if err != nil {
+ t.Fatalf("marshal claims: %v", err)
+ }
+ payload := base64.RawURLEncoding.EncodeToString(payloadJSON)
+ return header + "." + payload + ".sig"
+}
+
func TestBuildAuthorizeURL(t *testing.T) {
cfg := OAuthProviderConfig{
Issuer: "https://auth.example.com",
@@ -53,8 +66,30 @@ func TestBuildAuthorizeURL(t *testing.T) {
}
}
+func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
+ cfg := OpenAIOAuthConfig()
+ pkce := PKCECodes{CodeVerifier: "test-verifier", CodeChallenge: "test-challenge"}
+
+ u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
+ parsed, err := url.Parse(u)
+ if err != nil {
+ t.Fatalf("url.Parse() error: %v", err)
+ }
+ q := parsed.Query()
+
+ if q.Get("id_token_add_organizations") != "true" {
+ t.Errorf("id_token_add_organizations = %q, want true", q.Get("id_token_add_organizations"))
+ }
+ if q.Get("codex_cli_simplified_flow") != "true" {
+ t.Errorf("codex_cli_simplified_flow = %q, want true", q.Get("codex_cli_simplified_flow"))
+ }
+ if q.Get("originator") != "codex_cli_rs" {
+ t.Errorf("originator = %q, want codex_cli_rs", q.Get("originator"))
+ }
+}
+
func TestParseTokenResponse(t *testing.T) {
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "test-access-token",
"refresh_token": "test-refresh-token",
"expires_in": 3600,
@@ -84,6 +119,37 @@ func TestParseTokenResponse(t *testing.T) {
}
}
+func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
+ idToken := makeJWTForClaims(t, map[string]any{"chatgpt_account_id": "acc-id-from-id-token"})
+ resp := map[string]any{
+ "access_token": "opaque-access-token",
+ "refresh_token": "test-refresh-token",
+ "expires_in": 3600,
+ "id_token": idToken,
+ }
+ body, _ := json.Marshal(resp)
+
+ cred, err := parseTokenResponse(body, "openai")
+ if err != nil {
+ t.Fatalf("parseTokenResponse() error: %v", err)
+ }
+ if cred.AccountID != "acc-id-from-id-token" {
+ t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-id-from-id-token")
+ }
+}
+
+func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
+ token := makeJWTForClaims(t, map[string]any{
+ "organizations": []any{
+ map[string]any{"id": "org_from_orgs"},
+ },
+ })
+
+ if got := extractAccountID(token); got != "org_from_orgs" {
+ t.Errorf("extractAccountID() = %q, want %q", got, "org_from_orgs")
+ }
+}
+
func TestParseTokenResponseNoAccessToken(t *testing.T) {
body := []byte(`{"refresh_token": "test"}`)
_, err := parseTokenResponse(body, "openai")
@@ -94,7 +160,7 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) {
func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
idToken := makeJWTWithAccountID("acc-from-id")
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "not-a-jwt",
"refresh_token": "test-refresh-token",
"expires_in": 3600,
@@ -114,7 +180,9 @@ func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
func makeJWTWithAccountID(accountID string) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
- payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`))
+ payload := base64.RawURLEncoding.EncodeToString(
+ []byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`),
+ )
return header + "." + payload + ".sig"
}
@@ -135,7 +203,7 @@ func TestExchangeCodeForTokens(t *testing.T) {
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"expires_in": 3600,
@@ -174,7 +242,7 @@ func TestRefreshAccessToken(t *testing.T) {
return
}
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "refreshed-access-token",
"refresh_token": "refreshed-refresh-token",
"expires_in": 3600,
@@ -222,6 +290,37 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
}
}
+func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ resp := map[string]any{
+ "access_token": "new-access-token-only",
+ "expires_in": 3600,
+ }
+ json.NewEncoder(w).Encode(resp)
+ }))
+ defer server.Close()
+
+ cfg := OAuthProviderConfig{Issuer: server.URL, ClientID: "test-client"}
+ cred := &AuthCredential{
+ AccessToken: "old-access",
+ RefreshToken: "existing-refresh",
+ AccountID: "acc_existing",
+ Provider: "openai",
+ AuthMethod: "oauth",
+ }
+
+ refreshed, err := RefreshAccessToken(cred, cfg)
+ if err != nil {
+ t.Fatalf("RefreshAccessToken() error: %v", err)
+ }
+ if refreshed.RefreshToken != "existing-refresh" {
+ t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "existing-refresh")
+ }
+ if refreshed.AccountID != "acc_existing" {
+ t.Errorf("AccountID = %q, want %q", refreshed.AccountID, "acc_existing")
+ }
+}
+
func TestOpenAIOAuthConfig(t *testing.T) {
cfg := OpenAIOAuthConfig()
if cfg.Issuer != "https://auth.openai.com" {
diff --git a/pkg/auth/store.go b/pkg/auth/store.go
index 20724929a..64708421b 100644
--- a/pkg/auth/store.go
+++ b/pkg/auth/store.go
@@ -14,6 +14,8 @@ type AuthCredential struct {
ExpiresAt time.Time `json:"expires_at,omitempty"`
Provider string `json:"provider"`
AuthMethod string `json:"auth_method"`
+ Email string `json:"email,omitempty"`
+ ProjectID string `json:"project_id,omitempty"`
}
type AuthStore struct {
@@ -62,7 +64,7 @@ func LoadStore() (*AuthStore, error) {
func SaveStore(store *AuthStore) error {
path := authFilePath()
dir := filepath.Dir(path)
- if err := os.MkdirAll(dir, 0755); err != nil {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
@@ -70,7 +72,7 @@ func SaveStore(store *AuthStore) error {
if err != nil {
return err
}
- return os.WriteFile(path, data, 0600)
+ return os.WriteFile(path, data, 0o600)
}
func GetCredential(provider string) (*AuthCredential, error) {
diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go
index d96b460a1..f6793cfce 100644
--- a/pkg/auth/store_test.go
+++ b/pkg/auth/store_test.go
@@ -108,7 +108,7 @@ func TestStoreFilePermissions(t *testing.T) {
t.Fatalf("Stat() error: %v", err)
}
perm := info.Mode().Perm()
- if perm != 0600 {
+ if perm != 0o600 {
t.Errorf("file permissions = %o, want 0600", perm)
}
}
diff --git a/pkg/bus/bus.go b/pkg/bus/bus.go
index 6283251a4..58c0a25d5 100644
--- a/pkg/bus/bus.go
+++ b/pkg/bus/bus.go
@@ -9,6 +9,7 @@ type MessageBus struct {
inbound chan InboundMessage
outbound chan OutboundMessage
handlers map[string]MessageHandler
+ closed bool
mu sync.RWMutex
}
@@ -21,6 +22,11 @@ func NewMessageBus() *MessageBus {
}
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
+ mb.mu.RLock()
+ defer mb.mu.RUnlock()
+ if mb.closed {
+ return
+ }
mb.inbound <- msg
}
@@ -34,6 +40,11 @@ func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool)
}
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
+ mb.mu.RLock()
+ defer mb.mu.RUnlock()
+ if mb.closed {
+ return
+ }
mb.outbound <- msg
}
@@ -60,6 +71,12 @@ func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
}
func (mb *MessageBus) Close() {
+ mb.mu.Lock()
+ defer mb.mu.Unlock()
+ if mb.closed {
+ return
+ }
+ mb.closed = true
close(mb.inbound)
close(mb.outbound)
}
diff --git a/pkg/channels/base.go b/pkg/channels/base.go
index 8d2d9a65b..cd6419ebb 100644
--- a/pkg/channels/base.go
+++ b/pkg/channels/base.go
@@ -2,7 +2,6 @@ package channels
import (
"context"
- "fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -18,14 +17,14 @@ type Channel interface {
}
type BaseChannel struct {
- config interface{}
+ config any
bus *bus.MessageBus
running bool
name string
allowList []string
}
-func NewBaseChannel(name string, config interface{}, bus *bus.MessageBus, allowList []string) *BaseChannel {
+func NewBaseChannel(name string, config any, bus *bus.MessageBus, allowList []string) *BaseChannel {
return &BaseChannel{
config: config,
bus: bus,
@@ -87,17 +86,13 @@ func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []st
return
}
- // Build session key: channel:chatID
- sessionKey := fmt.Sprintf("%s:%s", c.name, chatID)
-
msg := bus.InboundMessage{
- Channel: c.name,
- SenderID: senderID,
- ChatID: chatID,
- Content: content,
- Media: media,
- SessionKey: sessionKey,
- Metadata: metadata,
+ Channel: c.name,
+ SenderID: senderID,
+ ChatID: chatID,
+ Content: content,
+ Media: media,
+ Metadata: metadata,
}
c.bus.PublishInbound(msg)
diff --git a/pkg/channels/dingtalk.go b/pkg/channels/dingtalk.go
index 263785c0c..662fba3b7 100644
--- a/pkg/channels/dingtalk.go
+++ b/pkg/channels/dingtalk.go
@@ -10,6 +10,7 @@ import (
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
+
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -108,7 +109,7 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("invalid session_webhook type for chat %s", msg.ChatID)
}
- logger.DebugCF("dingtalk", "Sending message", map[string]interface{}{
+ logger.DebugCF("dingtalk", "Sending message", map[string]any{
"chat_id": msg.ChatID,
"preview": utils.Truncate(msg.Content, 100),
})
@@ -120,12 +121,15 @@ func (c *DingTalkChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
// onChatBotMessageReceived implements the IChatBotMessageHandler function signature
// This is called by the Stream SDK when a new message arrives
// IChatBotMessageHandler is: func(c context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error)
-func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *chatbot.BotCallbackDataModel) ([]byte, error) {
+func (c *DingTalkChannel) onChatBotMessageReceived(
+ ctx context.Context,
+ data *chatbot.BotCallbackDataModel,
+) ([]byte, error) {
// Extract message content from Text field
content := data.Text.Content
if content == "" {
// Try to extract from Content interface{} if Text is empty
- if contentMap, ok := data.Content.(map[string]interface{}); ok {
+ if contentMap, ok := data.Content.(map[string]any); ok {
if textContent, ok := contentMap["content"].(string); ok {
content = textContent
}
@@ -155,7 +159,15 @@ func (c *DingTalkChannel) onChatBotMessageReceived(ctx context.Context, data *ch
"session_webhook": data.SessionWebhook,
}
- logger.DebugCF("dingtalk", "Received message", map[string]interface{}{
+ if data.ConversationType == "1" {
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
+ } else {
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = data.ConversationId
+ }
+
+ logger.DebugCF("dingtalk", "Received message", map[string]any{
"sender_nick": senderNick,
"sender_id": senderID,
"preview": utils.Truncate(content, 50),
@@ -184,7 +196,6 @@ func (c *DingTalkChannel) SendDirectReply(ctx context.Context, sessionWebhook, c
titleBytes,
contentBytes,
)
-
if err != nil {
return fmt.Errorf("failed to send reply: %w", err)
}
diff --git a/pkg/channels/discord.go b/pkg/channels/discord.go
index e65c99eec..20f3b267c 100644
--- a/pkg/channels/discord.go
+++ b/pkg/channels/discord.go
@@ -4,9 +4,12 @@ import (
"context"
"fmt"
"os"
+ "strings"
+ "sync"
"time"
"github.com/bwmarrin/discordgo"
+
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
@@ -25,6 +28,9 @@ type DiscordChannel struct {
config config.DiscordConfig
transcriber *voice.GroqTranscriber
ctx context.Context
+ typingMu sync.Mutex
+ typingStop map[string]chan struct{} // chatID → stop signal
+ botUserID string // stored for mention checking
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
@@ -41,6 +47,7 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC
config: cfg,
transcriber: nil,
ctx: context.Background(),
+ typingStop: make(map[string]chan struct{}),
}, nil
}
@@ -59,6 +66,14 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
logger.InfoC("discord", "Starting Discord bot")
c.ctx = ctx
+
+ // Get bot user ID before opening session to avoid race condition
+ botUser, err := c.session.User("@me")
+ if err != nil {
+ return fmt.Errorf("failed to get bot user: %w", err)
+ }
+ c.botUserID = botUser.ID
+
c.session.AddHandler(c.handleMessage)
if err := c.session.Open(); err != nil {
@@ -67,10 +82,6 @@ func (c *DiscordChannel) Start(ctx context.Context) error {
c.setRunning(true)
- botUser, err := c.session.User("@me")
- if err != nil {
- return fmt.Errorf("failed to get bot user: %w", err)
- }
logger.InfoCF("discord", "Discord bot connected", map[string]any{
"username": botUser.Username,
"user_id": botUser.ID,
@@ -83,6 +94,14 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
logger.InfoC("discord", "Stopping Discord bot")
c.setRunning(false)
+ // Stop all typing goroutines before closing session
+ c.typingMu.Lock()
+ for chatID, stop := range c.typingStop {
+ close(stop)
+ delete(c.typingStop, chatID)
+ }
+ c.typingMu.Unlock()
+
if err := c.session.Close(); err != nil {
return fmt.Errorf("failed to close discord session: %w", err)
}
@@ -91,6 +110,8 @@ func (c *DiscordChannel) Stop(ctx context.Context) error {
}
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ c.stopTyping(msg.ChatID)
+
if !c.IsRunning() {
return fmt.Errorf("discord bot not running")
}
@@ -100,15 +121,30 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("channel ID is empty")
}
- message := msg.Content
+ runes := []rune(msg.Content)
+ if len(runes) == 0 {
+ return nil
+ }
- // 使用传入的 ctx 进行超时控制
+ chunks := utils.SplitMessage(msg.Content, 2000) // Split messages into chunks, Discord length limit: 2000 chars
+
+ for _, chunk := range chunks {
+ if err := c.sendChunk(ctx, channelID, chunk); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content 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, message)
+ _, err := c.session.ChannelMessageSend(channelID, content)
done <- err
}()
@@ -123,7 +159,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
}
}
-// appendContent 安全地追加内容到现有文本
+// appendContent safely appends content to existing text
func appendContent(content, suffix string) string {
if content == "" {
return suffix
@@ -140,7 +176,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
- // 检查白名单,避免为被拒绝的用户下载附件和转录
+ // Check allowlist first to avoid downloading attachments and transcribing for rejected users
if !c.IsAllowed(m.Author.ID) {
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
"user_id": m.Author.ID,
@@ -148,6 +184,24 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
+ // If configured to only respond to mentions, check if bot is mentioned
+ // Skip this check for DMs (GuildID is empty) - DMs should always be responded to
+ if c.config.MentionOnly && m.GuildID != "" {
+ isMentioned := false
+ for _, mention := range m.Mentions {
+ if mention.ID == c.botUserID {
+ isMentioned = true
+ break
+ }
+ }
+ if !isMentioned {
+ logger.DebugCF("discord", "Message ignored - bot not mentioned", map[string]any{
+ "user_id": m.Author.ID,
+ })
+ return
+ }
+ }
+
senderID := m.Author.ID
senderName := m.Author.Username
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
@@ -155,10 +209,11 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
}
content := m.Content
+ content = c.stripBotMention(content)
mediaPaths := make([]string, 0, len(m.Attachments))
localFiles := make([]string, 0, len(m.Attachments))
- // 确保临时文件在函数返回时被清理
+ // Ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
@@ -182,7 +237,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout)
result, err := c.transcriber.Transcribe(ctx, localPath)
- cancel() // 立即释放context资源,避免在for循环中泄漏
+ cancel() // Release context resources immediately to avoid leaks in for loop
if err != nil {
logger.ErrorCF("discord", "Voice transcription failed", map[string]any{
@@ -222,12 +277,22 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
content = "[media only]"
}
+ // Start typing after all early returns — guaranteed to have a matching Send()
+ c.startTyping(m.ChannelID)
+
logger.DebugCF("discord", "Received message", map[string]any{
"sender_name": senderName,
"sender_id": senderID,
"preview": utils.Truncate(content, 50),
})
+ peerKind := "channel"
+ peerID := m.ChannelID
+ if m.GuildID == "" {
+ peerKind = "direct"
+ peerID = senderID
+ }
+
metadata := map[string]string{
"message_id": m.ID,
"user_id": senderID,
@@ -236,13 +301,73 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
"guild_id": m.GuildID,
"channel_id": m.ChannelID,
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
+ "peer_kind": peerKind,
+ "peer_id": peerID,
}
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
}
+// startTyping starts a continuous typing indicator loop for the given chatID.
+// It stops any existing typing loop for that chatID before starting a new one.
+func (c *DiscordChannel) startTyping(chatID string) {
+ c.typingMu.Lock()
+ // Stop existing loop for this chatID if any
+ if stop, ok := c.typingStop[chatID]; ok {
+ close(stop)
+ }
+ stop := make(chan struct{})
+ c.typingStop[chatID] = stop
+ c.typingMu.Unlock()
+
+ go func() {
+ if err := c.session.ChannelTyping(chatID); err != nil {
+ logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
+ }
+ ticker := time.NewTicker(8 * time.Second)
+ defer ticker.Stop()
+ timeout := time.After(5 * time.Minute)
+ for {
+ select {
+ case <-stop:
+ return
+ case <-timeout:
+ return
+ case <-c.ctx.Done():
+ return
+ case <-ticker.C:
+ if err := c.session.ChannelTyping(chatID); err != nil {
+ logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
+ }
+ }
+ }
+ }()
+}
+
+// stopTyping stops the typing indicator loop for the given chatID.
+func (c *DiscordChannel) stopTyping(chatID string) {
+ c.typingMu.Lock()
+ defer c.typingMu.Unlock()
+ if stop, ok := c.typingStop[chatID]; ok {
+ close(stop)
+ delete(c.typingStop, chatID)
+ }
+}
+
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
return utils.DownloadFile(url, filename, utils.DownloadOptions{
LoggerPrefix: "discord",
})
}
+
+// stripBotMention removes the bot mention from the message content.
+// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
+func (c *DiscordChannel) stripBotMention(text string) string {
+ if c.botUserID == "" {
+ return text
+ }
+ // Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID>
+ text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "")
+ text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "")
+ return strings.TrimSpace(text)
+}
diff --git a/pkg/channels/feishu_32.go b/pkg/channels/feishu_32.go
index 4e60fbc11..5109b8195 100644
--- a/pkg/channels/feishu_32.go
+++ b/pkg/channels/feishu_32.go
@@ -17,7 +17,9 @@ type FeishuChannel struct {
// NewFeishuChannel returns an error on 32-bit architectures where the Feishu SDK is not supported
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
- return nil, errors.New("feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config")
+ return nil, errors.New(
+ "feishu channel is not supported on 32-bit architectures (armv7l, 386, etc.). Please use a 64-bit system or disable feishu in your config",
+ )
}
// Start is a stub method to satisfy the Channel interface
diff --git a/pkg/channels/feishu_64.go b/pkg/channels/feishu_64.go
index 39dc40ac1..42e74980f 100644
--- a/pkg/channels/feishu_64.go
+++ b/pkg/channels/feishu_64.go
@@ -65,7 +65,7 @@ func (c *FeishuChannel) Start(ctx context.Context) error {
go func() {
if err := wsClient.Start(runCtx); err != nil {
- logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]interface{}{
+ logger.ErrorCF("feishu", "Feishu websocket stopped with error", map[string]any{
"error": err.Error(),
})
}
@@ -121,7 +121,7 @@ func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return fmt.Errorf("feishu api error: code=%d msg=%s", resp.Code, resp.Msg)
}
- logger.DebugCF("feishu", "Feishu message sent", map[string]interface{}{
+ logger.DebugCF("feishu", "Feishu message sent", map[string]any{
"chat_id": msg.ChatID,
})
@@ -165,7 +165,16 @@ func (c *FeishuChannel) handleMessageReceive(_ context.Context, event *larkim.P2
metadata["tenant_key"] = *sender.TenantKey
}
- logger.InfoCF("feishu", "Feishu message received", map[string]interface{}{
+ chatType := stringValue(message.ChatType)
+ if chatType == "p2p" {
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
+ } else {
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = chatID
+ }
+
+ logger.InfoCF("feishu", "Feishu message received", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 80),
diff --git a/pkg/channels/line.go b/pkg/channels/line.go
index ffb5533e8..44134996f 100644
--- a/pkg/channels/line.go
+++ b/pkg/channels/line.go
@@ -75,11 +75,11 @@ func (c *LINEChannel) Start(ctx context.Context) error {
// Fetch bot profile to get bot's userId for mention detection
if err := c.fetchBotInfo(); err != nil {
- logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]interface{}{
+ logger.WarnCF("line", "Failed to fetch bot info (mention detection disabled)", map[string]any{
"error": err.Error(),
})
} else {
- logger.InfoCF("line", "Bot info fetched", map[string]interface{}{
+ logger.InfoCF("line", "Bot info fetched", map[string]any{
"bot_user_id": c.botUserID,
"basic_id": c.botBasicID,
"display_name": c.botDisplayName,
@@ -100,12 +100,12 @@ func (c *LINEChannel) Start(ctx context.Context) error {
}
go func() {
- logger.InfoCF("line", "LINE webhook server listening", map[string]interface{}{
+ logger.InfoCF("line", "LINE webhook server listening", map[string]any{
"addr": addr,
"path": path,
})
if err := c.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
- logger.ErrorCF("line", "Webhook server error", map[string]interface{}{
+ logger.ErrorCF("line", "Webhook server error", map[string]any{
"error": err.Error(),
})
}
@@ -162,7 +162,7 @@ func (c *LINEChannel) Stop(ctx context.Context) error {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := c.httpServer.Shutdown(shutdownCtx); err != nil {
- logger.ErrorCF("line", "Webhook server shutdown error", map[string]interface{}{
+ logger.ErrorCF("line", "Webhook server shutdown error", map[string]any{
"error": err.Error(),
})
}
@@ -182,7 +182,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
- logger.ErrorCF("line", "Failed to read request body", map[string]interface{}{
+ logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
@@ -200,7 +200,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
Events []lineEvent `json:"events"`
}
if err := json.Unmarshal(body, &payload); err != nil {
- logger.ErrorCF("line", "Failed to parse webhook payload", map[string]interface{}{
+ logger.ErrorCF("line", "Failed to parse webhook payload", map[string]any{
"error": err.Error(),
})
http.Error(w, "Bad request", http.StatusBadRequest)
@@ -266,7 +266,7 @@ type lineMentionee struct {
func (c *LINEChannel) processEvent(event lineEvent) {
if event.Type != "message" {
- logger.DebugCF("line", "Ignoring non-message event", map[string]interface{}{
+ logger.DebugCF("line", "Ignoring non-message event", map[string]any{
"type": event.Type,
})
return
@@ -278,7 +278,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
var msg lineMessage
if err := json.Unmarshal(event.Message, &msg); err != nil {
- logger.ErrorCF("line", "Failed to parse message", map[string]interface{}{
+ logger.ErrorCF("line", "Failed to parse message", map[string]any{
"error": err.Error(),
})
return
@@ -286,7 +286,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
// In group chats, only respond when the bot is mentioned
if isGroup && !c.isBotMentioned(msg) {
- logger.DebugCF("line", "Ignoring group message without mention", map[string]interface{}{
+ logger.DebugCF("line", "Ignoring group message without mention", map[string]any{
"chat_id": chatID,
})
return
@@ -312,7 +312,7 @@ func (c *LINEChannel) processEvent(event lineEvent) {
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
- logger.DebugCF("line", "Failed to cleanup temp file", map[string]interface{}{
+ logger.DebugCF("line", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@@ -366,7 +366,15 @@ func (c *LINEChannel) processEvent(event lineEvent) {
"message_id": msg.ID,
}
- logger.DebugCF("line", "Received message", map[string]interface{}{
+ if isGroup {
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = chatID
+ } else {
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
+ }
+
+ logger.DebugCF("line", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"message_type": msg.Type,
@@ -497,7 +505,7 @@ func (c *LINEChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
tokenEntry := entry.(replyTokenEntry)
if time.Since(tokenEntry.timestamp) < lineReplyTokenMaxAge {
if err := c.sendReply(ctx, tokenEntry.token, msg.Content, quoteToken); err == nil {
- logger.DebugCF("line", "Message sent via Reply API", map[string]interface{}{
+ logger.DebugCF("line", "Message sent via Reply API", map[string]any{
"chat_id": msg.ChatID,
"quoted": quoteToken != "",
})
@@ -525,7 +533,7 @@ func buildTextMessage(content, quoteToken string) map[string]string {
// sendReply sends a message using the LINE Reply API.
func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteToken string) error {
- payload := map[string]interface{}{
+ payload := map[string]any{
"replyToken": replyToken,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
@@ -535,7 +543,7 @@ func (c *LINEChannel) sendReply(ctx context.Context, replyToken, content, quoteT
// sendPush sends a message using the LINE Push API.
func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken string) error {
- payload := map[string]interface{}{
+ payload := map[string]any{
"to": to,
"messages": []map[string]string{buildTextMessage(content, quoteToken)},
}
@@ -545,19 +553,19 @@ func (c *LINEChannel) sendPush(ctx context.Context, to, content, quoteToken stri
// sendLoading sends a loading animation indicator to the chat.
func (c *LINEChannel) sendLoading(chatID string) {
- payload := map[string]interface{}{
+ payload := map[string]any{
"chatId": chatID,
"loadingSeconds": 60,
}
if err := c.callAPI(c.ctx, lineLoadingEndpoint, payload); err != nil {
- logger.DebugCF("line", "Failed to send loading indicator", map[string]interface{}{
+ logger.DebugCF("line", "Failed to send loading indicator", map[string]any{
"error": err.Error(),
})
}
}
// callAPI makes an authenticated POST request to the LINE API.
-func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload interface{}) error {
+func (c *LINEChannel) callAPI(ctx context.Context, endpoint string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go
index 5fc19adbe..34ce62b20 100644
--- a/pkg/channels/maixcam.go
+++ b/pkg/channels/maixcam.go
@@ -18,14 +18,13 @@ type MaixCamChannel struct {
listener net.Listener
clients map[net.Conn]bool
clientsMux sync.RWMutex
- running bool
}
type MaixCamMessage struct {
- Type string `json:"type"`
- Tips string `json:"tips"`
- Timestamp float64 `json:"timestamp"`
- Data map[string]interface{} `json:"data"`
+ Type string `json:"type"`
+ Tips string `json:"tips"`
+ Timestamp float64 `json:"timestamp"`
+ Data map[string]any `json:"data"`
}
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
@@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC
BaseChannel: base,
config: cfg,
clients: make(map[net.Conn]bool),
- running: false,
}, nil
}
@@ -51,7 +49,7 @@ func (c *MaixCamChannel) Start(ctx context.Context) error {
c.listener = listener
c.setRunning(true)
- logger.InfoCF("maixcam", "MaixCam server listening", map[string]interface{}{
+ logger.InfoCF("maixcam", "MaixCam server listening", map[string]any{
"host": c.config.Host,
"port": c.config.Port,
})
@@ -73,14 +71,14 @@ func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
conn, err := c.listener.Accept()
if err != nil {
if c.running {
- logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{
+ logger.ErrorCF("maixcam", "Failed to accept connection", map[string]any{
"error": err.Error(),
})
}
return
}
- logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]interface{}{
+ logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]any{
"remote_addr": conn.RemoteAddr().String(),
})
@@ -114,7 +112,7 @@ func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
var msg MaixCamMessage
if err := decoder.Decode(&msg); err != nil {
if err.Error() != "EOF" {
- logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{
+ logger.ErrorCF("maixcam", "Failed to decode message", map[string]any{
"error": err.Error(),
})
}
@@ -135,14 +133,14 @@ func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) {
case "status":
c.handleStatusUpdate(msg)
default:
- logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{
+ logger.WarnCF("maixcam", "Unknown message type", map[string]any{
"type": msg.Type,
})
}
}
func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
- logger.InfoCF("maixcam", "", map[string]interface{}{
+ logger.InfoCF("maixcam", "", map[string]any{
"timestamp": msg.Timestamp,
"data": msg.Data,
})
@@ -172,13 +170,15 @@ func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
"y": fmt.Sprintf("%.0f", y),
"w": fmt.Sprintf("%.0f", w),
"h": fmt.Sprintf("%.0f", h),
+ "peer_kind": "channel",
+ "peer_id": "default",
}
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
}
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
- logger.InfoCF("maixcam", "Status update from MaixCam", map[string]interface{}{
+ logger.InfoCF("maixcam", "Status update from MaixCam", map[string]any{
"status": msg.Data,
})
}
@@ -216,7 +216,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
return fmt.Errorf("no connected MaixCam devices")
}
- response := map[string]interface{}{
+ response := map[string]any{
"type": "command",
"timestamp": float64(0),
"message": msg.Content,
@@ -231,7 +231,7 @@ func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
var sendErr error
for conn := range c.clients {
if _, err := conn.Write(data); err != nil {
- logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{
+ logger.ErrorCF("maixcam", "Failed to send to client", map[string]any{
"client": conn.RemoteAddr().String(),
"error": err.Error(),
})
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index b5af573ab..0a521a8c0 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -48,9 +48,9 @@ func (m *Manager) initChannels() error {
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
logger.DebugC("channels", "Attempting to initialize Telegram channel")
- telegram, err := NewTelegramChannel(m.config.Channels.Telegram, m.bus)
+ telegram, err := NewTelegramChannel(m.config, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -63,7 +63,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -76,7 +76,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Feishu channel")
feishu, err := NewFeishuChannel(m.config.Channels.Feishu, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Feishu channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -89,7 +89,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Discord channel")
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -102,7 +102,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -115,7 +115,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize QQ channel")
qq, err := NewQQChannel(m.config.Channels.QQ, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize QQ channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -128,7 +128,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize DingTalk channel")
dingtalk, err := NewDingTalkChannel(m.config.Channels.DingTalk, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize DingTalk channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -141,7 +141,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Slack channel")
slackCh, err := NewSlackChannel(m.config.Channels.Slack, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize Slack channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -154,7 +154,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize LINE channel")
line, err := NewLINEChannel(m.config.Channels.LINE, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize LINE channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -167,7 +167,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize OneBot channel")
onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
if err != nil {
- logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]any{
"error": err.Error(),
})
} else {
@@ -176,7 +176,33 @@ func (m *Manager) initChannels() error {
}
}
- logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
+ if m.config.Channels.WeCom.Enabled && m.config.Channels.WeCom.Token != "" {
+ logger.DebugC("channels", "Attempting to initialize WeCom channel")
+ wecom, err := NewWeComBotChannel(m.config.Channels.WeCom, m.bus)
+ if err != nil {
+ logger.ErrorCF("channels", "Failed to initialize WeCom channel", map[string]any{
+ "error": err.Error(),
+ })
+ } else {
+ m.channels["wecom"] = wecom
+ logger.InfoC("channels", "WeCom channel enabled successfully")
+ }
+ }
+
+ if m.config.Channels.WeComApp.Enabled && m.config.Channels.WeComApp.CorpID != "" {
+ logger.DebugC("channels", "Attempting to initialize WeCom App channel")
+ wecomApp, err := NewWeComAppChannel(m.config.Channels.WeComApp, m.bus)
+ if err != nil {
+ logger.ErrorCF("channels", "Failed to initialize WeCom App channel", map[string]any{
+ "error": err.Error(),
+ })
+ } else {
+ m.channels["wecom_app"] = wecomApp
+ logger.InfoC("channels", "WeCom App channel enabled successfully")
+ }
+ }
+
+ logger.InfoCF("channels", "Channel initialization completed", map[string]any{
"enabled_channels": len(m.channels),
})
@@ -200,11 +226,11 @@ func (m *Manager) StartAll(ctx context.Context) error {
go m.dispatchOutbound(dispatchCtx)
for name, channel := range m.channels {
- logger.InfoCF("channels", "Starting channel", map[string]interface{}{
+ logger.InfoCF("channels", "Starting channel", map[string]any{
"channel": name,
})
if err := channel.Start(ctx); err != nil {
- logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Failed to start channel", map[string]any{
"channel": name,
"error": err.Error(),
})
@@ -227,11 +253,11 @@ func (m *Manager) StopAll(ctx context.Context) error {
}
for name, channel := range m.channels {
- logger.InfoCF("channels", "Stopping channel", map[string]interface{}{
+ logger.InfoCF("channels", "Stopping channel", map[string]any{
"channel": name,
})
if err := channel.Stop(ctx); err != nil {
- logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Error stopping channel", map[string]any{
"channel": name,
"error": err.Error(),
})
@@ -262,14 +288,14 @@ func (m *Manager) dispatchOutbound(ctx context.Context) {
m.mu.RUnlock()
if !exists {
- logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{
+ logger.WarnCF("channels", "Unknown channel for outbound message", map[string]any{
"channel": msg.Channel,
})
continue
}
if err := channel.Send(ctx, msg); err != nil {
- logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{
+ logger.ErrorCF("channels", "Error sending message to channel", map[string]any{
"channel": msg.Channel,
"error": err.Error(),
})
@@ -284,13 +310,13 @@ func (m *Manager) GetChannel(name string) (Channel, bool) {
return channel, ok
}
-func (m *Manager) GetStatus() map[string]interface{} {
+func (m *Manager) GetStatus() map[string]any {
m.mu.RLock()
defer m.mu.RUnlock()
- status := make(map[string]interface{})
+ status := make(map[string]any)
for name, channel := range m.channels {
- status[name] = map[string]interface{}{
+ status[name] = map[string]any{
"enabled": true,
"running": channel.IsRunning(),
}
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index 5d97fab9c..cee8ad9d3 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -4,9 +4,11 @@ import (
"context"
"encoding/json"
"fmt"
+ "os"
"strconv"
"strings"
"sync"
+ "sync/atomic"
"time"
"github.com/gorilla/websocket"
@@ -14,20 +16,28 @@ import (
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/utils"
+ "github.com/sipeed/picoclaw/pkg/voice"
)
type OneBotChannel struct {
*BaseChannel
- config config.OneBotConfig
- conn *websocket.Conn
- ctx context.Context
- cancel context.CancelFunc
- dedup map[string]struct{}
- dedupRing []string
- dedupIdx int
- mu sync.Mutex
- writeMu sync.Mutex
- echoCounter int64
+ config config.OneBotConfig
+ conn *websocket.Conn
+ ctx context.Context
+ cancel context.CancelFunc
+ dedup map[string]struct{}
+ dedupRing []string
+ dedupIdx int
+ mu sync.Mutex
+ writeMu sync.Mutex
+ echoCounter int64
+ selfID int64
+ pending map[string]chan json.RawMessage
+ pendingMu sync.Mutex
+ transcriber *voice.GroqTranscriber
+ lastMessageID sync.Map
+ pendingEmojiMsg sync.Map
}
type oneBotRawEvent struct {
@@ -43,9 +53,11 @@ type oneBotRawEvent struct {
SelfID json.RawMessage `json:"self_id"`
Time json.RawMessage `json:"time"`
MetaEventType string `json:"meta_event_type"`
+ NoticeType string `json:"notice_type"`
Echo string `json:"echo"`
RetCode json.RawMessage `json:"retcode"`
- Status BotStatus `json:"status"`
+ Status json.RawMessage `json:"status"`
+ Data json.RawMessage `json:"data"`
}
type BotStatus struct {
@@ -53,42 +65,36 @@ type BotStatus struct {
Good bool `json:"good"`
}
+func isAPIResponse(raw json.RawMessage) bool {
+ if len(raw) == 0 {
+ return false
+ }
+ var s string
+ if json.Unmarshal(raw, &s) == nil {
+ return s == "ok" || s == "failed"
+ }
+ var bs BotStatus
+ if json.Unmarshal(raw, &bs) == nil {
+ return bs.Online || bs.Good
+ }
+ return false
+}
+
type oneBotSender struct {
UserID json.RawMessage `json:"user_id"`
Nickname string `json:"nickname"`
Card string `json:"card"`
}
-type oneBotEvent struct {
- PostType string
- MessageType string
- SubType string
- MessageID string
- UserID int64
- GroupID int64
- Content string
- RawContent string
- IsBotMentioned bool
- Sender oneBotSender
- SelfID int64
- Time int64
- MetaEventType string
-}
-
type oneBotAPIRequest struct {
- Action string `json:"action"`
- Params interface{} `json:"params"`
- Echo string `json:"echo,omitempty"`
+ Action string `json:"action"`
+ Params any `json:"params"`
+ Echo string `json:"echo,omitempty"`
}
-type oneBotSendPrivateMsgParams struct {
- UserID int64 `json:"user_id"`
- Message string `json:"message"`
-}
-
-type oneBotSendGroupMsgParams struct {
- GroupID int64 `json:"group_id"`
- Message string `json:"message"`
+type oneBotMessageSegment struct {
+ Type string `json:"type"`
+ Data map[string]any `json:"data"`
}
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
@@ -101,32 +107,53 @@ func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*One
dedup: make(map[string]struct{}, dedupSize),
dedupRing: make([]string, dedupSize),
dedupIdx: 0,
+ pending: make(map[string]chan json.RawMessage),
}, nil
}
+func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
+ c.transcriber = transcriber
+}
+
+func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
+ go func() {
+ _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]any{
+ "message_id": messageID,
+ "emoji_id": emojiID,
+ "set": set,
+ }, 5*time.Second)
+ if err != nil {
+ logger.DebugCF("onebot", "Failed to set emoji like", map[string]any{
+ "message_id": messageID,
+ "error": err.Error(),
+ })
+ }
+ }()
+}
+
func (c *OneBotChannel) Start(ctx context.Context) error {
if c.config.WSUrl == "" {
return fmt.Errorf("OneBot ws_url not configured")
}
- logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{
+ logger.InfoCF("onebot", "Starting OneBot channel", map[string]any{
"ws_url": c.config.WSUrl,
})
c.ctx, c.cancel = context.WithCancel(ctx)
if err := c.connect(); err != nil {
- logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{
+ logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]any{
"error": err.Error(),
})
} else {
go c.listen()
+ c.fetchSelfID()
}
if c.config.ReconnectInterval > 0 {
go c.reconnectLoop()
} else {
- // If reconnect is disabled but initial connection failed, we cannot recover
if c.conn == nil {
return fmt.Errorf("failed to connect to OneBot and reconnect is disabled")
}
@@ -152,14 +179,141 @@ func (c *OneBotChannel) connect() error {
return err
}
+ conn.SetPongHandler(func(appData string) error {
+ _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+ return nil
+ })
+ _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
+
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
+ go c.pinger(conn)
+
logger.InfoC("onebot", "WebSocket connected")
return nil
}
+func (c *OneBotChannel) pinger(conn *websocket.Conn) {
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-ticker.C:
+ c.writeMu.Lock()
+ err := conn.WriteMessage(websocket.PingMessage, nil)
+ c.writeMu.Unlock()
+ if err != nil {
+ logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]any{
+ "error": err.Error(),
+ })
+ return
+ }
+ }
+ }
+}
+
+func (c *OneBotChannel) fetchSelfID() {
+ resp, err := c.sendAPIRequest("get_login_info", nil, 5*time.Second)
+ if err != nil {
+ logger.WarnCF("onebot", "Failed to get_login_info", map[string]any{
+ "error": err.Error(),
+ })
+ return
+ }
+
+ type loginInfo struct {
+ UserID json.RawMessage `json:"user_id"`
+ Nickname string `json:"nickname"`
+ }
+ for _, extract := range []func() (*loginInfo, error){
+ func() (*loginInfo, error) {
+ var w struct {
+ Data loginInfo `json:"data"`
+ }
+ err := json.Unmarshal(resp, &w)
+ return &w.Data, err
+ },
+ func() (*loginInfo, error) {
+ var f loginInfo
+ err := json.Unmarshal(resp, &f)
+ return &f, err
+ },
+ } {
+ info, err := extract()
+ if err != nil || len(info.UserID) == 0 {
+ continue
+ }
+ if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {
+ atomic.StoreInt64(&c.selfID, uid)
+ logger.InfoCF("onebot", "Bot self ID retrieved", map[string]any{
+ "self_id": uid,
+ "nickname": info.Nickname,
+ })
+ return
+ }
+ }
+
+ logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]any{
+ "response": string(resp),
+ })
+}
+
+func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) {
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ return nil, fmt.Errorf("WebSocket not connected")
+ }
+
+ echo := fmt.Sprintf("api_%d_%d", time.Now().UnixNano(), atomic.AddInt64(&c.echoCounter, 1))
+
+ ch := make(chan json.RawMessage, 1)
+ c.pendingMu.Lock()
+ c.pending[echo] = ch
+ c.pendingMu.Unlock()
+
+ defer func() {
+ c.pendingMu.Lock()
+ delete(c.pending, echo)
+ c.pendingMu.Unlock()
+ }()
+
+ req := oneBotAPIRequest{
+ Action: action,
+ Params: params,
+ Echo: echo,
+ }
+
+ data, err := json.Marshal(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal API request: %w", err)
+ }
+
+ c.writeMu.Lock()
+ err = conn.WriteMessage(websocket.TextMessage, data)
+ c.writeMu.Unlock()
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to write API request: %w", err)
+ }
+
+ select {
+ case resp := <-ch:
+ return resp, nil
+ case <-time.After(timeout):
+ return nil, fmt.Errorf("API request %s timed out after %v", action, timeout)
+ case <-c.ctx.Done():
+ return nil, fmt.Errorf("context cancelled")
+ }
+}
+
func (c *OneBotChannel) reconnectLoop() {
interval := time.Duration(c.config.ReconnectInterval) * time.Second
if interval < 5*time.Second {
@@ -178,11 +332,12 @@ func (c *OneBotChannel) reconnectLoop() {
if conn == nil {
logger.InfoC("onebot", "Attempting to reconnect...")
if err := c.connect(); err != nil {
- logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{
+ logger.ErrorCF("onebot", "Reconnect failed", map[string]any{
"error": err.Error(),
})
} else {
go c.listen()
+ c.fetchSelfID()
}
}
}
@@ -197,6 +352,13 @@ func (c *OneBotChannel) Stop(ctx context.Context) error {
c.cancel()
}
+ c.pendingMu.Lock()
+ for echo, ch := range c.pending {
+ close(ch)
+ delete(c.pending, echo)
+ }
+ c.pendingMu.Unlock()
+
c.mu.Lock()
if c.conn != nil {
c.conn.Close()
@@ -225,10 +387,7 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
return err
}
- c.writeMu.Lock()
- c.echoCounter++
- echo := fmt.Sprintf("send_%d", c.echoCounter)
- c.writeMu.Unlock()
+ echo := fmt.Sprintf("send_%d", atomic.AddInt64(&c.echoCounter, 1))
req := oneBotAPIRequest{
Action: action,
@@ -246,73 +405,84 @@ func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
c.writeMu.Unlock()
if err != nil {
- logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{
+ logger.ErrorCF("onebot", "Failed to send message", map[string]any{
"error": err.Error(),
})
return err
}
+ if msgID, ok := c.pendingEmojiMsg.LoadAndDelete(msg.ChatID); ok {
+ if mid, ok := msgID.(string); ok && mid != "" {
+ c.setMsgEmojiLike(mid, 289, false)
+ }
+ }
+
return nil
}
-func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
+func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMessageSegment {
+ var segments []oneBotMessageSegment
+
+ if lastMsgID, ok := c.lastMessageID.Load(chatID); ok {
+ if msgID, ok := lastMsgID.(string); ok && msgID != "" {
+ segments = append(segments, oneBotMessageSegment{
+ Type: "reply",
+ Data: map[string]any{"id": msgID},
+ })
+ }
+ }
+
+ segments = append(segments, oneBotMessageSegment{
+ Type: "text",
+ Data: map[string]any{"text": content},
+ })
+
+ return segments
+}
+
+func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) {
chatID := msg.ChatID
+ segments := c.buildMessageSegments(chatID, msg.Content)
- if len(chatID) > 6 && chatID[:6] == "group:" {
- groupID, err := strconv.ParseInt(chatID[6:], 10, 64)
- if err != nil {
- return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID)
- }
- return "send_group_msg", oneBotSendGroupMsgParams{
- GroupID: groupID,
- Message: msg.Content,
- }, nil
+ var action, idKey string
+ var rawID string
+ if rest, ok := strings.CutPrefix(chatID, "group:"); ok {
+ action, idKey, rawID = "send_group_msg", "group_id", rest
+ } else if rest, ok := strings.CutPrefix(chatID, "private:"); ok {
+ action, idKey, rawID = "send_private_msg", "user_id", rest
+ } else {
+ action, idKey, rawID = "send_private_msg", "user_id", chatID
}
- if len(chatID) > 8 && chatID[:8] == "private:" {
- userID, err := strconv.ParseInt(chatID[8:], 10, 64)
- if err != nil {
- return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID)
- }
- return "send_private_msg", oneBotSendPrivateMsgParams{
- UserID: userID,
- Message: msg.Content,
- }, nil
- }
-
- userID, err := strconv.ParseInt(chatID, 10, 64)
+ id, err := strconv.ParseInt(rawID, 10, 64)
if err != nil {
- return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID)
+ return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID)
}
-
- return "send_private_msg", oneBotSendPrivateMsgParams{
- UserID: userID,
- Message: msg.Content,
- }, nil
+ return action, map[string]any{idKey: id, "message": segments}, nil
}
func (c *OneBotChannel) listen() {
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
+ return
+ }
+
for {
select {
case <-c.ctx.Done():
return
default:
- c.mu.Lock()
- conn := c.conn
- c.mu.Unlock()
-
- if conn == nil {
- logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
- return
- }
-
_, message, err := conn.ReadMessage()
if err != nil {
- logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
+ logger.ErrorCF("onebot", "WebSocket read error", map[string]any{
"error": err.Error(),
})
c.mu.Lock()
- if c.conn != nil {
+ if c.conn == conn {
c.conn.Close()
c.conn = nil
}
@@ -320,34 +490,48 @@ func (c *OneBotChannel) listen() {
return
}
- logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{
- "length": len(message),
- "payload": string(message),
- })
+ _ = conn.SetReadDeadline(time.Now().Add(60 * time.Second))
var raw oneBotRawEvent
if err := json.Unmarshal(message, &raw); err != nil {
- logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]any{
"error": err.Error(),
"payload": string(message),
})
continue
}
- if raw.Echo != "" || raw.Status.Online || raw.Status.Good {
- logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{
- "echo": raw.Echo,
- "status": raw.Status,
- })
+ logger.DebugCF("onebot", "WebSocket event", map[string]any{
+ "length": len(message),
+ "post_type": raw.PostType,
+ "sub_type": raw.SubType,
+ })
+
+ if raw.Echo != "" {
+ c.pendingMu.Lock()
+ ch, ok := c.pending[raw.Echo]
+ c.pendingMu.Unlock()
+
+ if ok {
+ select {
+ case ch <- message:
+ default:
+ }
+ } else {
+ logger.DebugCF("onebot", "Received API response (no waiter)", map[string]any{
+ "echo": raw.Echo,
+ "status": string(raw.Status),
+ })
+ }
continue
}
- logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{
- "post_type": raw.PostType,
- "message_type": raw.MessageType,
- "sub_type": raw.SubType,
- "meta_event_type": raw.MetaEventType,
- })
+ if isAPIResponse(raw.Status) {
+ logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]any{
+ "status": string(raw.Status),
+ })
+ continue
+ }
c.handleRawEvent(&raw)
}
@@ -386,9 +570,12 @@ func parseJSONString(raw json.RawMessage) string {
type parseMessageResult struct {
Text string
IsBotMentioned bool
+ Media []string
+ LocalFiles []string
+ ReplyTo string
}
-func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult {
+func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64) parseMessageResult {
if len(raw) == 0 {
return parseMessageResult{}
}
@@ -407,80 +594,208 @@ func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
}
- var segments []map[string]interface{}
- if err := json.Unmarshal(raw, &segments); err == nil {
- var text string
- mentioned := false
- selfIDStr := strconv.FormatInt(selfID, 10)
- for _, seg := range segments {
- segType, _ := seg["type"].(string)
- data, _ := seg["data"].(map[string]interface{})
- switch segType {
- case "text":
- if data != nil {
- if t, ok := data["text"].(string); ok {
- text += t
- }
+ var segments []map[string]any
+ if err := json.Unmarshal(raw, &segments); err != nil {
+ return parseMessageResult{}
+ }
+
+ var textParts []string
+ mentioned := false
+ selfIDStr := strconv.FormatInt(selfID, 10)
+ var media []string
+ var localFiles []string
+ var replyTo string
+
+ for _, seg := range segments {
+ segType, _ := seg["type"].(string)
+ data, _ := seg["data"].(map[string]any)
+
+ switch segType {
+ case "text":
+ if data != nil {
+ if t, ok := data["text"].(string); ok {
+ textParts = append(textParts, t)
}
- case "at":
- if data != nil && selfID > 0 {
- qqVal := fmt.Sprintf("%v", data["qq"])
- if qqVal == selfIDStr || qqVal == "all" {
- mentioned = true
+ }
+
+ case "at":
+ if data != nil && selfID > 0 {
+ qqVal := fmt.Sprintf("%v", data["qq"])
+ if qqVal == selfIDStr || qqVal == "all" {
+ mentioned = true
+ }
+ }
+
+ case "image", "video", "file":
+ if data != nil {
+ url, _ := data["url"].(string)
+ if url != "" {
+ defaults := map[string]string{"image": "image.jpg", "video": "video.mp4", "file": "file"}
+ filename := defaults[segType]
+ if f, ok := data["file"].(string); ok && f != "" {
+ filename = f
+ } else if n, ok := data["name"].(string); ok && n != "" {
+ filename = n
+ }
+ localPath := utils.DownloadFile(url, filename, utils.DownloadOptions{
+ LoggerPrefix: "onebot",
+ })
+ if localPath != "" {
+ media = append(media, localPath)
+ localFiles = append(localFiles, localPath)
+ textParts = append(textParts, fmt.Sprintf("[%s]", segType))
}
}
}
+
+ case "record":
+ if data != nil {
+ url, _ := data["url"].(string)
+ if url != "" {
+ localPath := utils.DownloadFile(url, "voice.amr", utils.DownloadOptions{
+ LoggerPrefix: "onebot",
+ })
+ if localPath != "" {
+ localFiles = append(localFiles, localPath)
+ if c.transcriber != nil && c.transcriber.IsAvailable() {
+ tctx, tcancel := context.WithTimeout(c.ctx, 30*time.Second)
+ result, err := c.transcriber.Transcribe(tctx, localPath)
+ tcancel()
+ if err != nil {
+ logger.WarnCF("onebot", "Voice transcription failed", map[string]any{
+ "error": err.Error(),
+ })
+ textParts = append(textParts, "[voice (transcription failed)]")
+ media = append(media, localPath)
+ } else {
+ textParts = append(textParts, fmt.Sprintf("[voice transcription: %s]", result.Text))
+ }
+ } else {
+ textParts = append(textParts, "[voice]")
+ media = append(media, localPath)
+ }
+ }
+ }
+ }
+
+ case "reply":
+ if data != nil {
+ if id, ok := data["id"]; ok {
+ replyTo = fmt.Sprintf("%v", id)
+ }
+ }
+
+ case "face":
+ if data != nil {
+ faceID, _ := data["id"]
+ textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID))
+ }
+
+ case "forward":
+ textParts = append(textParts, "[forward message]")
+
+ default:
+
}
- return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned}
}
- return parseMessageResult{}
+
+ return parseMessageResult{
+ Text: strings.TrimSpace(strings.Join(textParts, "")),
+ IsBotMentioned: mentioned,
+ Media: media,
+ LocalFiles: localFiles,
+ ReplyTo: replyTo,
+ }
}
func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
switch raw.PostType {
case "message":
- evt, err := c.normalizeMessageEvent(raw)
- if err != nil {
- logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{
- "error": err.Error(),
- })
- return
+ if userID, err := parseJSONInt64(raw.UserID); err == nil && userID > 0 {
+ if !c.IsAllowed(strconv.FormatInt(userID, 10)) {
+ logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
+ "user_id": userID,
+ })
+ return
+ }
}
- c.handleMessage(evt)
+ c.handleMessage(raw)
+
+ case "message_sent":
+ logger.DebugCF("onebot", "Bot sent message event", map[string]any{
+ "message_type": raw.MessageType,
+ "message_id": parseJSONString(raw.MessageID),
+ })
+
case "meta_event":
c.handleMetaEvent(raw)
+
case "notice":
- logger.DebugCF("onebot", "Notice event received", map[string]interface{}{
- "sub_type": raw.SubType,
- })
+ c.handleNoticeEvent(raw)
+
case "request":
- logger.DebugCF("onebot", "Request event received", map[string]interface{}{
+ logger.DebugCF("onebot", "Request event received", map[string]any{
"sub_type": raw.SubType,
})
+
case "":
- logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
+ logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]any{
"echo": raw.Echo,
"status": raw.Status,
})
+
default:
- logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
+ logger.DebugCF("onebot", "Unknown post_type", map[string]any{
"post_type": raw.PostType,
})
}
}
-func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) {
+func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
+ if raw.MetaEventType == "lifecycle" {
+ logger.InfoCF("onebot", "Lifecycle event", map[string]any{"sub_type": raw.SubType})
+ } else if raw.MetaEventType != "heartbeat" {
+ logger.DebugCF("onebot", "Meta event: "+raw.MetaEventType, nil)
+ }
+}
+
+func (c *OneBotChannel) handleNoticeEvent(raw *oneBotRawEvent) {
+ fields := map[string]any{
+ "notice_type": raw.NoticeType,
+ "sub_type": raw.SubType,
+ "group_id": parseJSONString(raw.GroupID),
+ "user_id": parseJSONString(raw.UserID),
+ "message_id": parseJSONString(raw.MessageID),
+ }
+ switch raw.NoticeType {
+ case "group_recall", "group_increase", "group_decrease",
+ "friend_add", "group_admin", "group_ban":
+ logger.InfoCF("onebot", "Notice: "+raw.NoticeType, fields)
+ default:
+ logger.DebugCF("onebot", "Notice: "+raw.NoticeType, fields)
+ }
+}
+
+func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
+ // Parse fields from raw event
userID, err := parseJSONInt64(raw.UserID)
if err != nil {
- return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID))
+ logger.WarnCF("onebot", "Failed to parse user_id", map[string]any{
+ "error": err.Error(),
+ "raw": string(raw.UserID),
+ })
+ return
}
groupID, _ := parseJSONInt64(raw.GroupID)
selfID, _ := parseJSONInt64(raw.SelfID)
- ts, _ := parseJSONInt64(raw.Time)
messageID := parseJSONString(raw.MessageID)
- parsed := parseMessageContentEx(raw.Message, selfID)
+ if selfID == 0 {
+ selfID = atomic.LoadInt64(&c.selfID)
+ }
+
+ parsed := c.parseMessageSegments(raw.Message, selfID)
isBotMentioned := parsed.IsBotMentioned
content := raw.RawMessage
@@ -495,147 +810,125 @@ func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent
}
}
+ if parsed.Text != "" && content != parsed.Text && (len(parsed.Media) > 0 || parsed.ReplyTo != "") {
+ content = parsed.Text
+ }
+
var sender oneBotSender
if len(raw.Sender) > 0 {
if err := json.Unmarshal(raw.Sender, &sender); err != nil {
- logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to parse sender", map[string]any{
"error": err.Error(),
"sender": string(raw.Sender),
})
}
}
- logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{
- "message_type": raw.MessageType,
- "user_id": userID,
- "group_id": groupID,
- "message_id": messageID,
- "content_len": len(content),
- "nickname": sender.Nickname,
- })
-
- return &oneBotEvent{
- PostType: raw.PostType,
- MessageType: raw.MessageType,
- SubType: raw.SubType,
- MessageID: messageID,
- UserID: userID,
- GroupID: groupID,
- Content: content,
- RawContent: raw.RawMessage,
- IsBotMentioned: isBotMentioned,
- Sender: sender,
- SelfID: selfID,
- Time: ts,
- MetaEventType: raw.MetaEventType,
- }, nil
-}
-
-func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
- switch raw.MetaEventType {
- case "lifecycle":
- logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{
- "sub_type": raw.SubType,
- })
- case "heartbeat":
- logger.DebugC("onebot", "Heartbeat received")
- default:
- logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{
- "meta_event_type": raw.MetaEventType,
- })
+ // Clean up temp files when done
+ if len(parsed.LocalFiles) > 0 {
+ defer func() {
+ for _, f := range parsed.LocalFiles {
+ if err := os.Remove(f); err != nil {
+ logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{
+ "path": f,
+ "error": err.Error(),
+ })
+ }
+ }
+ }()
}
-}
-func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
- if c.isDuplicate(evt.MessageID) {
- logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
- "message_id": evt.MessageID,
+ if c.isDuplicate(messageID) {
+ logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
+ "message_id": messageID,
})
return
}
- content := evt.Content
if content == "" {
- logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
- "message_id": evt.MessageID,
+ logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{
+ "message_id": messageID,
})
return
}
- senderID := strconv.FormatInt(evt.UserID, 10)
+ senderID := strconv.FormatInt(userID, 10)
var chatID string
metadata := map[string]string{
- "message_id": evt.MessageID,
+ "message_id": messageID,
}
- switch evt.MessageType {
+ if parsed.ReplyTo != "" {
+ metadata["reply_to_message_id"] = parsed.ReplyTo
+ }
+
+ switch raw.MessageType {
case "private":
chatID = "private:" + senderID
- logger.InfoCF("onebot", "Received private message", map[string]interface{}{
- "sender": senderID,
- "message_id": evt.MessageID,
- "length": len(content),
- "content": truncate(content, 100),
- })
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
case "group":
- groupIDStr := strconv.FormatInt(evt.GroupID, 10)
+ groupIDStr := strconv.FormatInt(groupID, 10)
chatID = "group:" + groupIDStr
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = groupIDStr
metadata["group_id"] = groupIDStr
- senderUserID, _ := parseJSONInt64(evt.Sender.UserID)
+ senderUserID, _ := parseJSONInt64(sender.UserID)
if senderUserID > 0 {
metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10)
}
- if evt.Sender.Card != "" {
- metadata["sender_name"] = evt.Sender.Card
- } else if evt.Sender.Nickname != "" {
- metadata["sender_name"] = evt.Sender.Nickname
+ if sender.Card != "" {
+ metadata["sender_name"] = sender.Card
+ } else if sender.Nickname != "" {
+ metadata["sender_name"] = sender.Nickname
}
- triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned)
+ triggered, strippedContent := c.checkGroupTrigger(content, isBotMentioned)
if !triggered {
- logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
+ logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]any{
"sender": senderID,
"group": groupIDStr,
- "is_mentioned": evt.IsBotMentioned,
+ "is_mentioned": isBotMentioned,
"content": truncate(content, 100),
})
return
}
content = strippedContent
- logger.InfoCF("onebot", "Received group message", map[string]interface{}{
- "sender": senderID,
- "group": groupIDStr,
- "message_id": evt.MessageID,
- "is_mentioned": evt.IsBotMentioned,
- "length": len(content),
- "content": truncate(content, 100),
- })
-
default:
- logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
- "type": evt.MessageType,
- "message_id": evt.MessageID,
- "user_id": evt.UserID,
+ logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{
+ "type": raw.MessageType,
+ "message_id": messageID,
+ "user_id": userID,
})
return
}
- if evt.Sender.Nickname != "" {
- metadata["nickname"] = evt.Sender.Nickname
- }
-
- logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{
- "sender_id": senderID,
- "chat_id": chatID,
- "content": truncate(content, 100),
+ logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{
+ "sender": senderID,
+ "chat_id": chatID,
+ "message_id": messageID,
+ "length": len(content),
+ "content": truncate(content, 100),
+ "media_count": len(parsed.Media),
})
- c.HandleMessage(senderID, chatID, content, []string{}, metadata)
+ if sender.Nickname != "" {
+ metadata["nickname"] = sender.Nickname
+ }
+
+ c.lastMessageID.Store(chatID, messageID)
+
+ if raw.MessageType == "group" && messageID != "" && messageID != "0" {
+ c.setMsgEmojiLike(messageID, 289, true)
+ c.pendingEmojiMsg.Store(chatID, messageID)
+ }
+
+ c.HandleMessage(senderID, chatID, content, parsed.Media, metadata)
}
func (c *OneBotChannel) isDuplicate(messageID string) bool {
@@ -668,7 +961,10 @@ func truncate(s string, n int) string {
return string(runes[:n]) + "..."
}
-func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) {
+func (c *OneBotChannel) checkGroupTrigger(
+ content string,
+ isBotMentioned bool,
+) (triggered bool, strippedContent string) {
if isBotMentioned {
return true, strings.TrimSpace(content)
}
diff --git a/pkg/channels/qq.go b/pkg/channels/qq.go
index 18b4ca0e0..b10776db6 100644
--- a/pkg/channels/qq.go
+++ b/pkg/channels/qq.go
@@ -47,47 +47,47 @@ func (c *QQChannel) Start(ctx context.Context) error {
logger.InfoC("qq", "Starting QQ bot (WebSocket mode)")
- // 创建 token source
+ // create token source
credentials := &token.QQBotCredentials{
AppID: c.config.AppID,
AppSecret: c.config.AppSecret,
}
c.tokenSource = token.NewQQBotTokenSource(credentials)
- // 创建子 context
+ // create child context
c.ctx, c.cancel = context.WithCancel(ctx)
- // 启动自动刷新 token 协程
+ // start auto-refresh token goroutine
if err := token.StartRefreshAccessToken(c.ctx, c.tokenSource); err != nil {
return fmt.Errorf("failed to start token refresh: %w", err)
}
- // 初始化 OpenAPI 客户端
+ // initialize OpenAPI client
c.api = botgo.NewOpenAPI(c.config.AppID, c.tokenSource).WithTimeout(5 * time.Second)
- // 注册事件处理器
+ // register event handlers
intent := event.RegisterHandlers(
c.handleC2CMessage(),
c.handleGroupATMessage(),
)
- // 获取 WebSocket 接入点
+ // get WebSocket endpoint
wsInfo, err := c.api.WS(c.ctx, nil, "")
if err != nil {
return fmt.Errorf("failed to get websocket info: %w", err)
}
- logger.InfoCF("qq", "Got WebSocket info", map[string]interface{}{
+ logger.InfoCF("qq", "Got WebSocket info", map[string]any{
"shards": wsInfo.Shards,
})
- // 创建并保存 sessionManager
+ // create and save sessionManager
c.sessionManager = botgo.NewSessionManager()
- // 在 goroutine 中启动 WebSocket 连接,避免阻塞
+ // start WebSocket connection in goroutine to avoid blocking
go func() {
if err := c.sessionManager.Start(wsInfo, c.tokenSource, &intent); err != nil {
- logger.ErrorCF("qq", "WebSocket session error", map[string]interface{}{
+ logger.ErrorCF("qq", "WebSocket session error", map[string]any{
"error": err.Error(),
})
c.setRunning(false)
@@ -116,15 +116,15 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return fmt.Errorf("QQ bot not running")
}
- // 构造消息
+ // construct message
msgToCreate := &dto.MessageToCreate{
Content: msg.Content,
}
- // C2C 消息发送
+ // send C2C message
_, err := c.api.PostC2CMessage(ctx, msg.ChatID, msgToCreate)
if err != nil {
- logger.ErrorCF("qq", "Failed to send C2C message", map[string]interface{}{
+ logger.ErrorCF("qq", "Failed to send C2C message", map[string]any{
"error": err.Error(),
})
return err
@@ -133,15 +133,15 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
return nil
}
-// handleC2CMessage 处理 QQ 私聊消息
+// handleC2CMessage handles QQ private messages
func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSC2CMessageData) error {
- // 去重检查
+ // deduplication check
if c.isDuplicate(data.ID) {
return nil
}
- // 提取用户信息
+ // extract user info
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@@ -150,21 +150,23 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
return nil
}
- // 提取消息内容
+ // extract message content
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty message, ignoring")
return nil
}
- logger.InfoCF("qq", "Received C2C message", map[string]interface{}{
+ logger.InfoCF("qq", "Received C2C message", map[string]any{
"sender": senderID,
"length": len(content),
})
- // 转发到消息总线
+ // forward to message bus
metadata := map[string]string{
"message_id": data.ID,
+ "peer_kind": "direct",
+ "peer_id": senderID,
}
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
@@ -173,15 +175,15 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
}
}
-// handleGroupATMessage 处理群@消息
+// handleGroupATMessage handles group @messages
func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return func(event *dto.WSPayload, data *dto.WSGroupATMessageData) error {
- // 去重检查
+ // deduplication check
if c.isDuplicate(data.ID) {
return nil
}
- // 提取用户信息
+ // extract user info
var senderID string
if data.Author != nil && data.Author.ID != "" {
senderID = data.Author.ID
@@ -190,23 +192,25 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
return nil
}
- // 提取消息内容(去掉 @ 机器人部分)
+ // extract message content (remove @bot part)
content := data.Content
if content == "" {
logger.DebugC("qq", "Received empty group message, ignoring")
return nil
}
- logger.InfoCF("qq", "Received group AT message", map[string]interface{}{
+ logger.InfoCF("qq", "Received group AT message", map[string]any{
"sender": senderID,
"group": data.GroupID,
"length": len(content),
})
- // 转发到消息总线(使用 GroupID 作为 ChatID)
+ // forward to message bus (use GroupID as ChatID)
metadata := map[string]string{
"message_id": data.ID,
"group_id": data.GroupID,
+ "peer_kind": "group",
+ "peer_id": data.GroupID,
}
c.HandleMessage(senderID, data.GroupID, content, []string{}, metadata)
@@ -215,7 +219,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
}
}
-// isDuplicate 检查消息是否重复
+// isDuplicate checks if message is duplicate
func (c *QQChannel) isDuplicate(messageID string) bool {
c.mu.Lock()
defer c.mu.Unlock()
@@ -226,9 +230,9 @@ func (c *QQChannel) isDuplicate(messageID string) bool {
c.processedIDs[messageID] = true
- // 简单清理:限制 map 大小
+ // simple cleanup: limit map size
if len(c.processedIDs) > 10000 {
- // 清空一半
+ // clear half
count := 0
for id := range c.processedIDs {
if count >= 5000 {
diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go
index d86d08a9d..f087aa8da 100644
--- a/pkg/channels/slack.go
+++ b/pkg/channels/slack.go
@@ -25,6 +25,7 @@ type SlackChannel struct {
api *slack.Client
socketClient *socketmode.Client
botUserID string
+ teamID string
transcriber *voice.GroqTranscriber
ctx context.Context
cancel context.CancelFunc
@@ -72,8 +73,9 @@ func (c *SlackChannel) Start(ctx context.Context) error {
return fmt.Errorf("slack auth test failed: %w", err)
}
c.botUserID = authResp.UserID
+ c.teamID = authResp.TeamID
- logger.InfoCF("slack", "Slack bot connected", map[string]interface{}{
+ logger.InfoCF("slack", "Slack bot connected", map[string]any{
"bot_user_id": c.botUserID,
"team": authResp.Team,
})
@@ -83,7 +85,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
go func() {
if err := c.socketClient.RunContext(c.ctx); err != nil {
if c.ctx.Err() == nil {
- logger.ErrorCF("slack", "Socket Mode connection error", map[string]interface{}{
+ logger.ErrorCF("slack", "Socket Mode connection error", map[string]any{
"error": err.Error(),
})
}
@@ -138,7 +140,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
})
}
- logger.DebugCF("slack", "Message sent", map[string]interface{}{
+ logger.DebugCF("slack", "Message sent", map[string]any{
"channel_id": channelID,
"thread_ts": threadTS,
})
@@ -198,9 +200,9 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
return
}
- // 检查白名单,避免为被拒绝的用户下载附件
+ // check allowlist to avoid downloading attachments for rejected users
if !c.IsAllowed(ev.User) {
- logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{
+ logger.DebugCF("slack", "Message rejected by allowlist", map[string]any{
"user_id": ev.User,
})
return
@@ -230,13 +232,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
content = c.stripBotMention(content)
var mediaPaths []string
- localFiles := []string{} // 跟踪需要清理的本地文件
+ localFiles := []string{} // track local files that need cleanup
- // 确保临时文件在函数返回时被清理
+ // ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
- logger.DebugCF("slack", "Failed to cleanup temp file", map[string]interface{}{
+ logger.DebugCF("slack", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@@ -259,7 +261,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
result, err := c.transcriber.Transcribe(ctx, localPath)
if err != nil {
- logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()})
+ logger.ErrorCF("slack", "Voice transcription failed", map[string]any{"error": err.Error()})
content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name)
} else {
content += fmt.Sprintf("\n[voice transcription: %s]", result.Text)
@@ -274,14 +276,24 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
return
}
+ peerKind := "channel"
+ peerID := channelID
+ if strings.HasPrefix(channelID, "D") {
+ peerKind = "direct"
+ peerID = senderID
+ }
+
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
+ "peer_kind": peerKind,
+ "peer_id": peerID,
+ "team_id": c.teamID,
}
- logger.DebugCF("slack", "Received message", map[string]interface{}{
+ logger.DebugCF("slack", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": chatID,
"preview": utils.Truncate(content, 50),
@@ -296,6 +308,13 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
+ if !c.IsAllowed(ev.User) {
+ logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
+ "user_id": ev.User,
+ })
+ return
+ }
+
senderID := ev.User
channelID := ev.Channel
threadTS := ev.ThreadTimeStamp
@@ -324,12 +343,22 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
return
}
+ mentionPeerKind := "channel"
+ mentionPeerID := channelID
+ if strings.HasPrefix(channelID, "D") {
+ mentionPeerKind = "direct"
+ mentionPeerID = senderID
+ }
+
metadata := map[string]string{
"message_ts": messageTS,
"channel_id": channelID,
"thread_ts": threadTS,
"platform": "slack",
"is_mention": "true",
+ "peer_kind": mentionPeerKind,
+ "peer_id": mentionPeerID,
+ "team_id": c.teamID,
}
c.HandleMessage(senderID, chatID, content, nil, metadata)
@@ -345,6 +374,13 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
c.socketClient.Ack(*event.Request)
}
+ if !c.IsAllowed(cmd.UserID) {
+ logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
+ "user_id": cmd.UserID,
+ })
+ return
+ }
+
senderID := cmd.UserID
channelID := cmd.ChannelID
chatID := channelID
@@ -359,9 +395,12 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"platform": "slack",
"is_command": "true",
"trigger_id": cmd.TriggerID,
+ "peer_kind": "channel",
+ "peer_id": channelID,
+ "team_id": c.teamID,
}
- logger.DebugCF("slack", "Slash command received", map[string]interface{}{
+ logger.DebugCF("slack", "Slash command received", map[string]any{
"sender_id": senderID,
"command": cmd.Command,
"text": utils.Truncate(content, 50),
@@ -376,7 +415,7 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string {
downloadURL = file.URLPrivate
}
if downloadURL == "" {
- logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID})
+ logger.ErrorCF("slack", "No download URL for file", map[string]any{"file_id": file.ID})
return ""
}
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index b14b1632e..5cd51e8bc 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -12,6 +12,8 @@ import (
"time"
"github.com/mymmrac/telego"
+ "github.com/mymmrac/telego/telegohandler"
+ th "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil"
"github.com/sipeed/picoclaw/pkg/bus"
@@ -24,7 +26,8 @@ import (
type TelegramChannel struct {
*BaseChannel
bot *telego.Bot
- config config.TelegramConfig
+ commands TelegramCommander
+ config *config.Config
chatIDs map[string]int64
transcriber *voice.GroqTranscriber
placeholders sync.Map // chatID -> messageID
@@ -41,30 +44,39 @@ func (c *thinkingCancel) Cancel() {
}
}
-func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
+func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChannel, error) {
var opts []telego.BotOption
+ telegramCfg := cfg.Channels.Telegram
- if cfg.Proxy != "" {
- proxyURL, parseErr := url.Parse(cfg.Proxy)
+ if telegramCfg.Proxy != "" {
+ proxyURL, parseErr := url.Parse(telegramCfg.Proxy)
if parseErr != nil {
- return nil, fmt.Errorf("invalid proxy URL %q: %w", cfg.Proxy, parseErr)
+ return nil, fmt.Errorf("invalid proxy URL %q: %w", telegramCfg.Proxy, parseErr)
}
opts = append(opts, telego.WithHTTPClient(&http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
}))
+ } else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
+ // Use environment proxy if configured
+ opts = append(opts, telego.WithHTTPClient(&http.Client{
+ Transport: &http.Transport{
+ Proxy: http.ProxyFromEnvironment,
+ },
+ }))
}
- bot, err := telego.NewBot(cfg.Token, opts...)
+ bot, err := telego.NewBot(telegramCfg.Token, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
- base := NewBaseChannel("telegram", cfg, bus, cfg.AllowFrom)
+ base := NewBaseChannel("telegram", telegramCfg, bus, telegramCfg.AllowFrom)
return &TelegramChannel{
BaseChannel: base,
+ commands: NewTelegramCommands(bot, cfg),
bot: bot,
config: cfg,
chatIDs: make(map[string]int64),
@@ -88,26 +100,41 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return fmt.Errorf("failed to start long polling: %w", err)
}
+ bh, err := telegohandler.NewBotHandler(c.bot, updates)
+ if err != nil {
+ return fmt.Errorf("failed to create bot handler: %w", err)
+ }
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ c.commands.Help(ctx, message)
+ return nil
+ }, th.CommandEqual("help"))
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.commands.Start(ctx, message)
+ }, th.CommandEqual("start"))
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.commands.Show(ctx, message)
+ }, th.CommandEqual("show"))
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.commands.List(ctx, message)
+ }, th.CommandEqual("list"))
+
+ bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
+ return c.handleMessage(ctx, &message)
+ }, th.AnyMessage())
+
c.setRunning(true)
- logger.InfoCF("telegram", "Telegram bot connected", map[string]interface{}{
+ logger.InfoCF("telegram", "Telegram bot connected", map[string]any{
"username": c.bot.Username(),
})
+ go bh.Start()
+
go func() {
- for {
- select {
- case <-ctx.Done():
- return
- case update, ok := <-updates:
- if !ok {
- logger.InfoC("telegram", "Updates channel closed, reconnecting...")
- return
- }
- if update.Message != nil {
- c.handleMessage(ctx, update)
- }
- }
- }
+ <-ctx.Done()
+ bh.Stop()
}()
return nil
@@ -155,7 +182,7 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
tgMsg.ParseMode = telego.ModeHTML
if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
- logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]interface{}{
+ logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
"error": err.Error(),
})
tgMsg.ParseMode = ""
@@ -166,30 +193,27 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return nil
}
-func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Update) {
- message := update.Message
+func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Message) error {
if message == nil {
- return
+ return fmt.Errorf("message is nil")
}
user := message.From
if user == nil {
- return
+ return fmt.Errorf("message sender (user) is nil")
}
- userID := fmt.Sprintf("%d", user.ID)
- senderID := userID
+ senderID := fmt.Sprintf("%d", user.ID)
if user.Username != "" {
- senderID = fmt.Sprintf("%s|%s", userID, user.Username)
+ senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
}
- // 检查白名单,避免为被拒绝的用户下载附件
- if !c.IsAllowed(userID) && !c.IsAllowed(senderID) {
- logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
- "user_id": userID,
- "username": user.Username,
+ // check allowlist to avoid downloading attachments for rejected users
+ if !c.IsAllowed(senderID) {
+ logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
+ "user_id": senderID,
})
- return
+ return nil
}
chatID := message.Chat.ID
@@ -197,13 +221,13 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
content := ""
mediaPaths := []string{}
- localFiles := []string{} // 跟踪需要清理的本地文件
+ localFiles := []string{} // track local files that need cleanup
- // 确保临时文件在函数返回时被清理
+ // ensure temp files are cleaned up when function returns
defer func() {
for _, file := range localFiles {
if err := os.Remove(file); err != nil {
- logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]interface{}{
+ logger.DebugCF("telegram", "Failed to cleanup temp file", map[string]any{
"file": file,
"error": err.Error(),
})
@@ -222,7 +246,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
content += message.Caption
}
- if message.Photo != nil && len(message.Photo) > 0 {
+ if len(message.Photo) > 0 {
photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(ctx, photo.FileID)
if photoPath != "" {
@@ -231,7 +255,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
- content += fmt.Sprintf("[image: photo]")
+ content += "[image: photo]"
}
}
@@ -243,24 +267,24 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
transcribedText := ""
if c.transcriber != nil && c.transcriber.IsAvailable() {
- ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
+ transcriberCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
- result, err := c.transcriber.Transcribe(ctx, voicePath)
+ result, err := c.transcriber.Transcribe(transcriberCtx, voicePath)
if err != nil {
- logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{
+ logger.ErrorCF("telegram", "Voice transcription failed", map[string]any{
"error": err.Error(),
"path": voicePath,
})
- transcribedText = fmt.Sprintf("[voice (transcription failed)]")
+ transcribedText = "[voice (transcription failed)]"
} else {
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
- logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{
+ logger.InfoCF("telegram", "Voice transcribed successfully", map[string]any{
"text": result.Text,
})
}
} else {
- transcribedText = fmt.Sprintf("[voice]")
+ transcribedText = "[voice]"
}
if content != "" {
@@ -278,7 +302,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
- content += fmt.Sprintf("[audio]")
+ content += "[audio]"
}
}
@@ -290,7 +314,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
if content != "" {
content += "\n"
}
- content += fmt.Sprintf("[file]")
+ content += "[file]"
}
}
@@ -298,7 +322,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
content = "[empty message]"
}
- logger.DebugCF("telegram", "Received message", map[string]interface{}{
+ logger.DebugCF("telegram", "Received message", map[string]any{
"sender_id": senderID,
"chat_id": fmt.Sprintf("%d", chatID),
"preview": utils.Truncate(content, 50),
@@ -307,7 +331,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
// Thinking indicator
err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping))
if err != nil {
- logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{
+ logger.ErrorCF("telegram", "Failed to send chat action", map[string]any{
"error": err.Error(),
})
}
@@ -330,21 +354,31 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
c.placeholders.Store(chatIDStr, pID)
}
+ peerKind := "direct"
+ peerID := fmt.Sprintf("%d", user.ID)
+ if message.Chat.Type != "private" {
+ peerKind = "group"
+ peerID = fmt.Sprintf("%d", chatID)
+ }
+
metadata := map[string]string{
"message_id": fmt.Sprintf("%d", message.MessageID),
"user_id": fmt.Sprintf("%d", user.ID),
"username": user.Username,
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
+ "peer_kind": peerKind,
+ "peer_id": peerID,
}
- c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
+ c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
+ return nil
}
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
- logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{
+ logger.ErrorCF("telegram", "Failed to get photo file", map[string]any{
"error": err.Error(),
})
return ""
@@ -359,7 +393,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
}
url := c.bot.FileDownloadURL(file.FilePath)
- logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url})
+ logger.DebugCF("telegram", "File URL", map[string]any{"url": url})
// Use FilePath as filename for better identification
filename := file.FilePath + ext
@@ -371,7 +405,7 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st
func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {
- logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{
+ logger.ErrorCF("telegram", "Failed to get file", map[string]any{
"error": err.Error(),
})
return ""
@@ -429,7 +463,11 @@ func markdownToTelegramHTML(text string) string {
for i, code := range codeBlocks.codes {
escaped := escapeHTML(code)
- text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("