+
+---
+
+🦐 **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/picoclaw_community_roadmap_260216.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
+
+# 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": "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 7da16565f..bb0bdfb28 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -12,7 +12,7 @@
-[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [English](README.md)
+[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
@@ -174,47 +174,37 @@ picoclaw onboard
```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": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
+ "model": "gpt4"
}
},
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_TELEGRAM_BOT_TOKEN",
+ "allow_from": []
}
- },
- "tools": {
- "web": {
- "search": {
- "api_key": "YOUR_BRAVE_API_KEY",
- "max_results": 5
- }
- },
- "cron": {
- "exec_timeout_minutes": 5
- }
- },
- "heartbeat": {
- "enabled": true,
- "interval": 30
}
}
```
**3. API キーの取得**
-- **LLM プロバイダー**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+- **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) · [Qwen](https://dashscope.console.aliyun.com)
- **Web 検索**(任意): [Brave Search](https://brave.com/search/api) - 無料枠あり(月 2000 リクエスト)
> **注意**: 完全な設定テンプレートは `config.example.json` を参照してください。
-**3. チャット**
+**4. チャット**
```bash
picoclaw agent -m "What is 2+2?"
@@ -226,7 +216,7 @@ picoclaw agent -m "What is 2+2?"
## 💬 チャットアプリ
-Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
+Telegram、Discord、QQ、DingTalk、LINE、WeCom で PicoClaw と会話できます
| チャネル | セットアップ |
|---------|------------|
@@ -235,6 +225,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます
| **QQ** | 簡単(AppID + AppSecret) |
| **DingTalk** | 普通(アプリ認証情報) |
| **LINE** | 普通(認証情報 + Webhook URL) |
+| **WeCom** | 普通(CorpID + Webhook設定) |
Telegram(推奨)
@@ -430,6 +421,87 @@ picoclaw gateway
+
+WeCom (企業微信)
+
+PicoClaw は2種類の WeCom 統合をサポートしています:
+
+**オプション1: WeCom Bot (智能ロボット)** - 簡単な設定、グループチャット対応
+**オプション2: WeCom App (自作アプリ)** - より多機能、アクティブメッセージング対応
+
+詳細な設定手順は [WeCom App Configuration Guide](docs/wecom-app-configuration.md) を参照してください。
+
+**クイックセットアップ - WeCom Bot:**
+
+**1. ボットを作成**
+
+* WeCom 管理コンソール → グループチャット → グループボットを追加
+* Webhook URL をコピー(形式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**2. 設定**
+
+```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": []
+ }
+ }
+}
+```
+
+**クイックセットアップ - WeCom App:**
+
+**1. アプリを作成**
+
+* WeCom 管理コンソール → アプリ管理 → アプリを作成
+* **AgentId** と **Secret** をコピー
+* "マイ会社" ページで **CorpID** をコピー
+
+**2. メッセージ受信を設定**
+
+* アプリ詳細で "メッセージを受信" → "APIを設定" をクリック
+* URL を `http://your-server:18792/webhook/wecom-app` に設定
+* **Token** と **EncodingAESKey** を生成
+
+**3. 設定**
+
+```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. 起動**
+
+```bash
+picoclaw gateway
+```
+
+> **注意**: WeCom App は Webhook コールバック用にポート 18792 を開放する必要があります。本番環境では HTTPS 用のリバースプロキシを使用してください。
+
+
+
## ⚙️ 設定
設定ファイル: `~/.picoclaw/config.json`
@@ -621,6 +693,22 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
- `PICOCLAW_HEARTBEAT_ENABLED=false` で無効化
- `PICOCLAW_HEARTBEAT_INTERVAL=60` で間隔変更
+### プロバイダー
+
+> [!NOTE]
+> Groq は Whisper による無料の音声文字起こしを提供しています。設定すると、Telegram の音声メッセージが自動的に文字起こしされます。
+
+| プロバイダー | 用途 | API キー取得先 |
+| --- | --- | --- |
+| `gemini` | LLM(Gemini 直接) | [aistudio.google.com](https://aistudio.google.com) |
+| `zhipu` | LLM(Zhipu 直接) | [bigmodel.cn](https://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) |
+| `cerebras` | LLM(Cerebras 直接) | [cerebras.ai](https://cerebras.ai) |
+
### 基本設定
1. **設定ファイルの作成:**
@@ -666,10 +754,10 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
},
"providers": {
"openrouter": {
- "apiKey": "sk-or-v1-xxx"
+ "api_key": "sk-or-v1-xxx"
},
"groq": {
- "apiKey": "gsk_xxx"
+ "api_key": "gsk_xxx"
}
},
"channels": {
@@ -688,17 +776,17 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
},
"feishu": {
"enabled": false,
- "appId": "cli_xxx",
- "appSecret": "xxx",
- "encryptKey": "",
- "verificationToken": "",
+ "app_id": "cli_xxx",
+ "app_secret": "xxx",
+ "encrypt_key": "",
+ "verification_token": "",
"allow_from": []
}
},
"tools": {
"web": {
"search": {
- "apiKey": "BSA..."
+ "api_key": "BSA..."
}
},
"cron": {
@@ -714,6 +802,163 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
+### モデル設定 (model_list)
+
+> **新機能!** PicoClaw は現在 **モデル中心** の設定アプローチを採用しています。`ベンダー/モデル` 形式(例: `zhipu/glm-4.7`)を指定するだけで、新しいプロバイダーを追加できます—**コードの変更は一切不要!**
+
+この設計は、柔軟なプロバイダー選択による **マルチエージェントサポート** も可能にします:
+
+- **異なるエージェント、異なるプロバイダー** : 各エージェントは独自の LLM プロバイダーを使用可能
+- **フォールバックモデル** : 耐障性のため、プライマリモデルとフォールバックモデルを設定可能
+- **ロードバランシング** : 複数のエンドポイントにリクエストを分散
+- **集中設定管理** : すべてのプロバイダーを一箇所で管理
+
+#### 📋 サポートされているすべてのベンダー
+
+| ベンダー | `model` プレフィックス | デフォルト API Base | プロトコル | API キー |
+|-------------|-----------------|---------------------|----------|---------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [キーを取得](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [キーを取得](https://console.anthropic.com) |
+| **Zhipu 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 (Alibaba)** | `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** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [キーを取得](https://console.volcengine.com) |
+| **ShengsuanYun** | `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-..."
+}
+```
+
+**Zhipu AI (GLM)**
+```json
+{
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+}
+```
+
+**Anthropic (OAuth使用)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "auth_method": "oauth"
+}
+```
+> OAuth認証を設定するには、`picoclaw auth login --provider anthropic` を実行してください。
+
+#### ロードバランシング
+
+同じモデル名で複数のエンドポイントを設定すると、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) を参照してください。
+
## CLI リファレンス
| コマンド | 説明 |
@@ -746,9 +991,14 @@ Web 検索を有効にするには:
{
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": true,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
}
}
@@ -771,5 +1021,7 @@ Web 検索を有効にするには:
|---------|--------|------------|
| **OpenRouter** | 月 200K トークン | 複数モデル(Claude, GPT-4 など) |
| **Zhipu** | 月 200K トークン | 中国ユーザー向け最適 |
+| **Qwen** | 無料枠あり | 通義千問 (Qwen) |
| **Brave Search** | 月 2000 クエリ | Web 検索機能 |
| **Groq** | 無料枠あり | 高速推論(Llama, Mixtral) |
+| **Cerebras** | 無料枠あり | 高速推論(Llama, Qwen など) |
diff --git a/README.md b/README.md
index d6a3d5696..7bc7b1089 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
- [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | **English**
+ [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | **English**
---
@@ -209,18 +209,24 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
+ "model": "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": {
"brave": {
@@ -237,6 +243,8 @@ picoclaw onboard
}
```
+> **New**: The `model_list` configuration format allows zero-code provider addition. See [Model Configuration](#model-configuration-model_list) for details.
+
**3. Get API Keys**
* **LLM Provider**: [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)
@@ -256,7 +264,7 @@ That's it! You have a working AI assistant in 2 minutes.
## 💬 Chat Apps
-Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
+Talk to your picoclaw through Telegram, Discord, DingTalk, LINE, or WeCom
| Channel | Setup |
| ------------ | ---------------------------------- |
@@ -265,6 +273,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE
| **QQ** | Easy (AppID + AppSecret) |
| **DingTalk** | Medium (app credentials) |
| **LINE** | Medium (credentials + webhook URL) |
+| **WeCom** | Medium (CorpID + webhook setup) |
Telegram (Recommended)
@@ -326,7 +335,8 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"],
+ "mention_only": false
}
}
}
@@ -339,6 +349,10 @@ picoclaw gateway
* Bot Permissions: `Send Messages`, `Read Message History`
* Open the generated invite URL and add the bot to your server
+**Optional: Mention-only mode**
+
+Set `"mention_only": true` to make the bot respond only when @-mentioned. Useful for shared servers where you want the bot to respond only when explicitly called.
+
**6. Run**
```bash
@@ -404,7 +418,7 @@ picoclaw gateway
}
```
-> Set `allow_from` to empty to allow all users, or specify QQ numbers to restrict access.
+> Set `allow_from` to empty to allow all users, or specify DingTalk user IDs to restrict access.
**3. Run**
@@ -464,6 +478,87 @@ picoclaw gateway
+
+WeCom (企业微信)
+
+PicoClaw supports two types of WeCom integration:
+
+**Option 1: WeCom Bot (智能机器人)** - Easier setup, supports group chats
+**Option 2: WeCom App (自建应用)** - More features, proactive messaging
+
+See [WeCom App Configuration Guide](docs/wecom-app-configuration.md) for detailed setup instructions.
+
+**Quick Setup - WeCom Bot:**
+
+**1. Create a bot**
+
+* Go to WeCom Admin Console → Group Chat → Add Group Bot
+* Copy the webhook URL (format: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**2. Configure**
+
+```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": []
+ }
+ }
+}
+```
+
+**Quick Setup - WeCom App:**
+
+**1. Create an app**
+
+* Go to WeCom Admin Console → App Management → Create App
+* Copy **AgentId** and **Secret**
+* Go to "My Company" page, copy **CorpID**
+
+**2. Configure receive message**
+
+* In App details, click "Receive Message" → "Set API"
+* Set URL to `http://your-server:18792/webhook/wecom-app`
+* Generate **Token** and **EncodingAESKey**
+
+**3. Configure**
+
+```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. Run**
+
+```bash
+picoclaw gateway
+```
+
+> **Note**: WeCom App requires opening port 18792 for webhook callbacks. Use a reverse proxy for HTTPS.
+
+
+
## Join the Agent Social Network
Connect Picoclaw to the Agent Social Network simply by sending a single message via the CLI or any integrated Chat App.
@@ -677,7 +772,193 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
| `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) |
| `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) |
| `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) |
+| `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
+| `cerebras` | LLM (Cerebras direct) | [cerebras.ai](https://cerebras.ai) |
+
+### Model Configuration (model_list)
+
+> **What's New?** PicoClaw now uses a **model-centric** configuration approach. Simply specify `vendor/model` format (e.g., `zhipu/glm-4.7`) to add new providers—**zero code changes required!**
+
+This design also enables **multi-agent support** with flexible provider selection:
+
+- **Different agents, different providers**: Each agent can use its own LLM provider
+- **Model fallbacks**: Configure primary and fallback models for resilience
+- **Load balancing**: Distribute requests across multiple endpoints
+- **Centralized configuration**: Manage all providers in one place
+
+#### 📋 All Supported Vendors
+
+| Vendor | `model` Prefix | Default API Base | Protocol | API Key |
+|--------|----------------|------------------|----------|---------|
+| **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) |
+| **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) |
+| **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) |
+| **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [Get Key](https://platform.deepseek.com) |
+| **Google Gemini** | `gemini/` | `https://generativelanguage.googleapis.com/v1beta` | OpenAI | [Get Key](https://aistudio.google.com/api-keys) |
+| **Groq** | `groq/` | `https://api.groq.com/openai/v1` | OpenAI | [Get Key](https://console.groq.com) |
+| **Moonshot** | `moonshot/` | `https://api.moonshot.cn/v1` | OpenAI | [Get Key](https://platform.moonshot.cn) |
+| **通义千问 (Qwen)** | `qwen/` | `https://dashscope.aliyuncs.com/compatible-mode/v1` | OpenAI | [Get Key](https://dashscope.console.aliyun.com) |
+| **NVIDIA** | `nvidia/` | `https://integrate.api.nvidia.com/v1` | OpenAI | [Get Key](https://build.nvidia.com) |
+| **Ollama** | `ollama/` | `http://localhost:11434/v1` | OpenAI | Local (no key needed) |
+| **OpenRouter** | `openrouter/` | `https://openrouter.ai/api/v1` | OpenAI | [Get Key](https://openrouter.ai/keys) |
+| **VLLM** | `vllm/` | `http://localhost:8000/v1` | OpenAI | Local |
+| **Cerebras** | `cerebras/` | `https://api.cerebras.ai/v1` | OpenAI | [Get Key](https://cerebras.ai) |
+| **火山引擎** | `volcengine/` | `https://ark.cn-beijing.volces.com/api/v3` | OpenAI | [Get Key](https://console.volcengine.com) |
+| **神算云** | `shengsuanyun/` | `https://router.shengsuanyun.com/api/v1` | OpenAI | - |
+| **Antigravity** | `antigravity/` | Google Cloud | Custom | OAuth only |
+| **GitHub Copilot** | `github-copilot/` | `localhost:4321` | gRPC | - |
+
+#### Basic Configuration
+
+```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"
+ }
+ }
+}
+```
+
+#### Vendor-Specific Examples
+
+**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 (with API key)**
+```json
+{
+ "model_name": "claude-sonnet-4.6",
+ "model": "anthropic/claude-sonnet-4.6",
+ "api_key": "sk-ant-your-key"
+}
+```
+> Run `picoclaw auth login --provider anthropic` to paste your API token.
+
+**Ollama (local)**
+```json
+{
+ "model_name": "llama3",
+ "model": "ollama/llama3"
+}
+```
+
+**Custom Proxy/API**
+```json
+{
+ "model_name": "my-custom-model",
+ "model": "openai/custom-model",
+ "api_base": "https://my-proxy.com/v1",
+ "api_key": "sk-..."
+}
+```
+
+#### Load Balancing
+
+Configure multiple endpoints for the same model name—PicoClaw will automatically round-robin between them:
+
+```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 from Legacy `providers` Config
+
+The old `providers` configuration is **deprecated** but still supported for backward compatibility.
+
+**Old Config (deprecated):**
+```json
+{
+ "providers": {
+ "zhipu": {
+ "api_key": "your-key",
+ "api_base": "https://open.bigmodel.cn/api/paas/v4"
+ }
+ },
+ "agents": {
+ "defaults": {
+ "provider": "zhipu",
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+**New Config (recommended):**
+```json
+{
+ "model_list": [
+ {
+ "model_name": "glm-4.7",
+ "model": "zhipu/glm-4.7",
+ "api_key": "your-key"
+ }
+ ],
+ "agents": {
+ "defaults": {
+ "model": "glm-4.7"
+ }
+ }
+}
+```
+
+For detailed migration guide, see [docs/migration/model-list-migration.md](docs/migration/model-list-migration.md).
### Provider Architecture
@@ -883,3 +1164,4 @@ This happens when another instance of the bot is running. Make sure only one `pi
| **Zhipu** | 200K tokens/month | Best for Chinese users |
| **Brave Search** | 2000 queries/month | Web search functionality |
| **Groq** | Free tier available | Fast inference (Llama, Mixtral) |
+| **Cerebras** | Free tier available | Fast inference (Llama, Qwen, etc.) |
diff --git a/README.pt-br.md b/README.pt-br.md
index fa73465dd..ec8fe8e1c 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -14,7 +14,7 @@
- [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Português**
+ [中文](README.zh.md) | [日本語](README.ja.md) | **Português** | [Tiếng Việt](README.vi.md) | [Français](README.fr.md) | [English](README.md)
---
@@ -213,19 +213,17 @@ picoclaw onboard
```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": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
- }
- },
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
+ "model": "gpt4"
}
},
"tools": {
@@ -263,7 +261,7 @@ 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 ou LINE.
+Converse com seu PicoClaw via Telegram, Discord, DingTalk, LINE ou WeCom.
| Canal | Nível de Configuração |
| --- | --- |
@@ -272,6 +270,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
| **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)
@@ -290,7 +289,7 @@ Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE.
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -333,7 +332,7 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allowFrom": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"]
}
}
}
@@ -471,6 +470,87 @@ picoclaw gateway
+
+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.
@@ -684,6 +764,8 @@ O subagente tem acesso às ferramentas (message, web_search, etc.) e pode se com
| `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) |
@@ -795,6 +877,163 @@ picoclaw agent -m "Ola, como vai?"
+### 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 |
@@ -849,7 +1088,7 @@ Adicione a key em `~/.picoclaw/config.json` se usar o Brave:
"tools": {
"web": {
"brave": {
- "enabled": true,
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
@@ -880,3 +1119,4 @@ Isso acontece quando outra instância do bot está em execução. Certifique-se
| **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
index e629eaa9b..161842933 100644
--- a/README.vi.md
+++ b/README.vi.md
@@ -14,7 +14,7 @@
-**Tiếng Việt** | [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md)
+[中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **Tiếng Việt** | [Français](README.fr.md) | [English](README.md)
---
@@ -193,32 +193,24 @@ picoclaw onboard
```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": {
- "workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
- "max_tokens": 8192,
- "temperature": 0.7,
- "max_tool_iterations": 20
+ "model": "gpt4"
}
},
- "providers": {
- "openrouter": {
- "api_key": "xxx",
- "api_base": "https://openrouter.ai/api/v1"
- }
- },
- "tools": {
- "web": {
- "brave": {
- "enabled": false,
- "api_key": "YOUR_BRAVE_API_KEY",
- "max_results": 5
- },
- "duckduckgo": {
- "enabled": true,
- "max_results": 5
- }
+ "channels": {
+ "telegram": {
+ "enabled": true,
+ "token": "YOUR_TELEGRAM_BOT_TOKEN",
+ "allow_from": []
}
}
}
@@ -243,7 +235,7 @@ Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 p
## 💬 Tích hợp ứng dụng Chat
-Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE.
+Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk, LINE hoặc WeCom.
| Kênh | Mức độ thiết lập |
| --- | --- |
@@ -252,6 +244,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE.
| **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)
@@ -451,6 +444,87 @@ picoclaw gateway
+
+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.
@@ -665,6 +739,8 @@ Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và
| `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
@@ -772,6 +848,163 @@ picoclaw agent -m "Xin chào"
+### 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ả |
@@ -826,7 +1059,7 @@ Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave:
"tools": {
"web": {
"brave": {
- "enabled": true,
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
diff --git a/README.zh.md b/README.zh.md
index 42bd20be4..ab896b6c0 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -14,7 +14,7 @@
- **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [Tiếng Việt](README.vi.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)
---
@@ -218,23 +218,34 @@ picoclaw onboard
"agents": {
"defaults": {
"workspace": "~/.picoclaw/workspace",
- "model": "glm-4.7",
+ "model": "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
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
},
"cron": {
@@ -245,6 +256,8 @@ picoclaw onboard
```
+> **新功能**: `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)
@@ -265,14 +278,15 @@ picoclaw agent -m "2+2 等于几?"
## 💬 聊天应用集成 (Chat Apps)
-通过 Telegram, Discord 或钉钉与您的 PicoClaw 对话。
+通过 Telegram, Discord, 钉钉或企业微信与您的 PicoClaw 对话。
| 渠道 | 设置难度 |
| --- | --- |
| **Telegram** | 简单 (仅需 token) |
| **Discord** | 简单 (bot token + intents) |
| **QQ** | 简单 (AppID + AppSecret) |
-| **钉钉 (DingTalk)** | 中等 (app credentials) |
+| **钉钉 (DingTalk)** | 中等 (应用凭证) |
+| **企业微信 (WeCom)** | 中等 (企业ID + Webhook配置) |
Telegram (推荐)
@@ -336,7 +350,8 @@ picoclaw gateway
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
- "allow_from": ["YOUR_USER_ID"]
+ "allow_from": ["YOUR_USER_ID"],
+ "mention_only": false
}
}
}
@@ -430,11 +445,93 @@ picoclaw gateway
+
+企业微信 (WeCom)
+
+PicoClaw 支持两种企业微信集成方式:
+
+**选项1: 智能机器人 (WeCom Bot)** - 设置更简单,支持群聊
+**选项2: 自建应用 (WeCom App)** - 功能更丰富,支持主动推送消息
+
+详见 [企业微信自建应用配置指南](docs/wecom-app-configuration.md)。
+
+**快速设置 - 智能机器人:**
+
+**1. 创建机器人**
+
+* 前往企业微信管理后台 → 群聊 → 添加群机器人
+* 复制 Webhook URL (格式: `https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx`)
+
+**2. 配置**
+
+```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": []
+ }
+ }
+}
+```
+
+**快速设置 - 自建应用:**
+
+**1. 创建应用**
+
+* 前往企业微信管理后台 → 应用管理 → 创建应用
+* 复制 **AgentId** 和 **Secret**
+* 前往"我的企业"页面,复制 **CorpID**
+
+**2. 配置接收消息**
+
+* 在应用详情页,点击"接收消息" → "设置API"
+* 设置 URL 为 `http://your-server:18792/webhook/wecom-app`
+* 生成 **Token** 和 **EncodingAESKey**
+
+**3. 配置**
+
+```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. 运行**
+
+```bash
+picoclaw gateway
+
+```
+
+> **注意**: 自建应用需要开放 18792 端口用于接收 Webhook 回调。生产环境建议使用反向代理配置 HTTPS。
+
+
+
## 加入 Agent 社交网络
只需通过 CLI 或任何集成的聊天应用发送一条消息,即可将 PicoClaw 连接到 Agent 社交网络。
-**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai**](https://clawdchat.ai)
+**阅读 `https://clawdchat.ai/skill.md` 并按照说明加入 [ClawdChat.ai](https://clawdchat.ai)
## ⚙️ 配置详解
@@ -554,7 +651,193 @@ Agent 读取 HEARTBEAT.md
| `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) 配置示例
@@ -580,8 +863,8 @@ Agent 读取 HEARTBEAT.md
"zhipu": {
"api_key": "Your API Key",
"api_base": "https://open.bigmodel.cn/api/paas/v4"
- },
- },
+ }
+ }
}
```
@@ -644,8 +927,14 @@ 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": {
@@ -712,9 +1001,14 @@ Discord: [https://discord.gg/V4sAZ9XWpN](https://discord.gg/V4sAZ9XWpN)
{
"tools": {
"web": {
- "search": {
+ "brave": {
+ "enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
+ },
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
}
}
}
@@ -741,4 +1035,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
+| **Groq** | 提供免费层级 | 极速推理 (Llama, Mixtral) |
+| **Cerebras** | 提供免费层级 | 极速推理 (Llama, Qwen 等) |
\ No newline at end of file
diff --git a/cmd/picoclaw/cmd_agent.go b/cmd/picoclaw/cmd_agent.go
new file mode 100644
index 000000000..6d6ff935f
--- /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.Model = 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.Model = 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.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)
+ }
+}
diff --git a/cmd/picoclaw/cmd_auth.go b/cmd/picoclaw/cmd_auth.go
new file mode 100644
index 000000000..729c56177
--- /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.Model = "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.Model = "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.Model = "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.Model = "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.Model)
+}
+
+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..9a3b6aa19
--- /dev/null
+++ b/cmd/picoclaw/cmd_gateway.go
@@ -0,0 +1,238 @@
+// PicoClaw - Ultra-lightweight personal AI agent
+// License: MIT
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "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.Model = 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
+ 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)
+ }
+
+ 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..07296784e
--- /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.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 != ""
+ 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 36bf2ea83..1e4b393f8 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -7,43 +7,16 @@
package main
import (
- "bufio"
- "context"
- "embed"
"fmt"
"io"
- "io/fs"
- "net/http"
"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/health"
- "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
@@ -168,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 ")
@@ -216,1218 +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
- 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
- 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
- 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")
- }
- }
- if onebotChannel, ok := channelManager.GetChannel("onebot"); ok {
- if oc, ok := onebotChannel.(*channels.OneBotChannel); ok {
- oc.SetTranscriber(transcriber)
- logger.InfoC("voice", "Groq transcription attached to OneBot 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]interface{}{"error": err.Error()})
- }
- }()
- fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
-
- go agentLoop.Run(ctx)
-
- sigChan := make(chan os.Signal, 1)
- 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 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, restrict bool, execTimeout time.Duration, config *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, config)
- 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 0168eca5b..5e49de2a7 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -3,12 +3,48 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
- "model": "glm-4.7",
+ "model": "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,
@@ -21,6 +57,13 @@
"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": {
@@ -70,9 +113,36 @@
"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": ""
@@ -111,9 +181,21 @@
"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": ""
}
},
"tools": {
@@ -123,6 +205,10 @@
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
+ "duckduckgo": {
+ "enabled": true,
+ "max_results": 5
+ },
"perplexity": {
"enabled": false,
"api_key": "pplx-xxx",
@@ -135,6 +221,21 @@
"mcp": {
"enabled": false,
"servers": {}
+ },
+ "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"
+ }
+ }
}
},
"heartbeat": {
@@ -149,4 +250,4 @@
"host": "0.0.0.0",
"port": 18790
}
-}
\ No newline at end of file
+}
diff --git a/docker-compose.yml b/docker-compose.yml
index 32e8ee339..c268b01cd 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -10,6 +10,9 @@ 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:/home/picoclaw/.picoclaw/config.json:ro
- picoclaw-workspace:/home/picoclaw/.picoclaw/workspace
@@ -29,6 +32,9 @@ 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:/home/picoclaw/.picoclaw/config.json:ro
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/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/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md
index cfcc30f17..95de768c6 100644
--- a/docs/picoclaw_community_roadmap_260216.md
+++ b/docs/picoclaw_community_roadmap_260216.md
@@ -71,14 +71,14 @@ Interested in a specific feature? You can "claim" these tasks and start building
* Support for OneBot, additional platforms
* attachments (images, audio, video, files).
* **Skills:**
- * Implementing `find_skill` to discover tools via [openclaw/skills](https://github.com/openclaw/skills) and other platforms.
+ * Implementing `find_skill` to discover tools via [ClawhHub](https://clawhub.ai) and other platforms.
* **Operations:** * MCP Support.
* Android operations (e.g., botdrop).
* Browser automation via CDP or ActionBook.
* **Multi-Agent Ecosystem:**
- * **Basic Model-Agnet** S
+ * **Basic Model-Agent**
* **Model Routing:** Small models for easy tasks, large models for hard ones (to save tokens).
* **Swarm Mode.**
* **AIEOS Integration.**
diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md
index 8777ddbd6..8aba1aa91 100644
--- a/docs/tools_configuration.md
+++ b/docs/tools_configuration.md
@@ -9,8 +9,8 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
"tools": {
"web": { ... },
"exec": { ... },
- "approval": { ... },
- "cron": { ... }
+ "cron": { ... },
+ "skills": { ... }
}
}
```
@@ -83,25 +83,12 @@ By default, PicoClaw blocks the following dangerous commands:
"custom_deny_patterns": [
"\\brm\\s+-r\\b",
"\\bkillall\\s+python"
- ],
+ ]
}
}
}
```
-## Approval Tool
-
-The approval tool controls permissions for dangerous operations.
-
-| Config | Type | Default | Description |
-|--------|------|---------|-------------|
-| `enabled` | bool | true | Enable approval functionality |
-| `write_file` | bool | true | Require approval for file writes |
-| `edit_file` | bool | true | Require approval for file edits |
-| `append_file` | bool | true | Require approval for file appends |
-| `exec` | bool | true | Require approval for command execution |
-| `timeout_minutes` | int | 5 | Approval timeout in minutes |
-
## Cron Tool
The cron tool is used for scheduling periodic tasks.
@@ -110,6 +97,40 @@ The cron tool is used for scheduling periodic tasks.
|--------|------|---------|-------------|
| `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__`:
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/pkg/agent/context.go b/pkg/agent/context.go
index cf5ce2913..a9db5afdd 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,
@@ -254,13 +308,13 @@ func (cb *ContextBuilder) loadSkills() string {
}
// 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
index 54a5396e7..dfbef9fbc 100644
--- a/pkg/agent/instance.go
+++ b/pkg/agent/instance.go
@@ -21,6 +21,8 @@ type AgentInstance struct {
Fallbacks []string
Workspace string
MaxIterations int
+ MaxTokens int
+ Temperature float64
ContextWindow int
Provider providers.LLMProvider
Sessions *session.SessionManager
@@ -39,7 +41,7 @@ func NewAgentInstance(
provider providers.LLMProvider,
) *AgentInstance {
workspace := resolveAgentWorkspace(agentCfg, defaults)
- os.MkdirAll(workspace, 0755)
+ os.MkdirAll(workspace, 0o755)
model := resolveAgentModel(agentCfg, defaults)
fallbacks := resolveAgentFallbacks(agentCfg, defaults)
@@ -76,6 +78,16 @@ func NewAgentInstance(
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,
@@ -90,7 +102,9 @@ func NewAgentInstance(
Fallbacks: fallbacks,
Workspace: workspace,
MaxIterations: maxIter,
- ContextWindow: defaults.MaxTokens,
+ MaxTokens: maxTokens,
+ Temperature: temperature,
+ ContextWindow: maxTokens,
Provider: provider,
Sessions: sessionsManager,
ContextBuilder: contextBuilder,
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 fc83007c2..e3efea17b 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -24,6 +24,7 @@ import (
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/providers"
"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"
@@ -80,7 +81,12 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
}
// 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) {
+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 {
@@ -118,8 +124,21 @@ func registerSharedTools(cfg *config.Config, msgBus *bus.MessageBus, registry *A
})
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 {
@@ -280,7 +299,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",
@@ -317,7 +339,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,
@@ -356,7 +378,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
}
logger.InfoCF("agent", "Routed message",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"session_key": sessionKey,
"matched_by": route.MatchedBy,
@@ -379,7 +401,7 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
}
logger.InfoCF("agent", "Processing system message",
- map[string]interface{}{
+ map[string]any{
"sender_id": msg.SenderID,
"chat_id": msg.ChatID,
})
@@ -404,7 +426,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,
@@ -437,7 +459,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
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", map[string]interface{}{"error": err.Error()})
+ logger.WarnCF("agent", "Failed to record last channel", map[string]any{"error": err.Error()})
}
}
}
@@ -499,7 +521,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 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,
@@ -510,7 +532,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
}
// runLLMIteration executes the LLM call loop with tool handling.
-func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance, 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
@@ -518,7 +545,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
iteration++
logger.DebugCF("agent", "LLM iteration",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"max": agent.MaxIterations,
@@ -529,20 +556,20 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// Log LLM request details
logger.DebugCF("agent", "LLM request",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"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),
@@ -556,9 +583,9 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
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]interface{}{
- "max_tokens": 8192,
- "temperature": 0.7,
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, model, map[string]any{
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
})
},
)
@@ -568,13 +595,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
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]interface{}{"agent_id": agent.ID, "iteration": iteration})
+ map[string]any{"agent_id": agent.ID, "iteration": iteration})
}
return fbResult.Response, nil
}
- return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]interface{}{
- "max_tokens": 8192,
- "temperature": 0.7,
+ return agent.Provider.Chat(ctx, messages, providerToolDefs, agent.Model, map[string]any{
+ "max_tokens": agent.MaxTokens,
+ "temperature": agent.Temperature,
})
}
@@ -593,7 +620,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
strings.Contains(errMsg, "length")
if isContextError && retry < maxRetries {
- logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]interface{}{
+ logger.WarnCF("agent", "Context window error detected, attempting compression", map[string]any{
"error": err.Error(),
"retry": retry,
})
@@ -620,7 +647,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
if err != nil {
logger.ErrorCF("agent", "LLM call failed",
- map[string]interface{}{
+ map[string]any{
"agent_id": agent.ID,
"iteration": iteration,
"error": err.Error(),
@@ -632,7 +659,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
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),
@@ -640,16 +667,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
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,
})
@@ -658,15 +690,26 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
Role: "assistant",
Content: response.Content,
}
- 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)
@@ -675,11 +718,11 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Execute tool calls
- for _, tc := range response.ToolCalls {
+ 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,
@@ -694,14 +737,21 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
// 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 := agent.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 {
@@ -711,7 +761,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
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),
})
@@ -802,31 +852,24 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
mid := len(conversation) / 2
// New history structure:
- // 1. System Prompt
- // 2. [Summary of dropped part] - synthesized
- // 3. Second half of conversation
- // 4. Last message
-
- // Simplified approach for emergency: Drop first half of conversation
- // and rely on existing summary if present, or create a placeholder.
+ // 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)
- newHistory = append(newHistory, history[0]) // System prompt
- // Add a note about compression
- compressionNote := fmt.Sprintf("[System: Emergency compression dropped %d oldest messages due to context limit]", droppedCount)
- // If there was an existing summary, we might lose it if it was in the dropped part (which is just messages).
- // The summary is stored separately in session.Summary, so it persists!
- // We just need to ensure the user knows there's a gap.
-
- // We only modify the messages list here
- newHistory = append(newHistory, providers.Message{
- Role: "system",
- Content: compressionNote,
- })
+ // 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
@@ -835,7 +878,7 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
agent.Sessions.SetHistory(sessionKey, newHistory)
agent.Sessions.Save(sessionKey)
- logger.WarnCF("agent", "Forced compression executed", map[string]interface{}{
+ logger.WarnCF("agent", "Forced compression executed", map[string]any{
"session_key": sessionKey,
"dropped_msgs": droppedCount,
"new_count": len(newHistory),
@@ -843,8 +886,8 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
}
// 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 {
@@ -853,7 +896,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
// Tools info
toolsList := agent.Tools.List()
- info["tools"] = map[string]interface{}{
+ info["tools"] = map[string]any{
"count": len(toolsList),
"names": toolsList,
}
@@ -862,7 +905,7 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
info["skills"] = agent.ContextBuilder.GetSkillsInfo()
// Agents info
- info["agents"] = map[string]interface{}{
+ info["agents"] = map[string]any{
"count": len(al.registry.ListAgentIDs()),
"ids": al.registry.ListAgentIDs(),
}
@@ -876,49 +919,49 @@ 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)
+ fmt.Fprintf(&sb, " [%d] Role: %s\n", i, msg.Role)
if len(msg.ToolCalls) > 0 {
- result += " ToolCalls:\n"
+ 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.
@@ -967,11 +1010,21 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
s1, _ := al.summarizeBatch(ctx, agent, part1, "")
s2, _ := al.summarizeBatch(ctx, agent, part2, "")
- mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2)
- resp, err := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, agent.Model, map[string]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 {
@@ -993,20 +1046,35 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) {
}
// summarizeBatch summarizes a batch of messages.
-func (al *AgentLoop) summarizeBatch(ctx context.Context, agent *AgentInstance, 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 := agent.Provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, agent.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
}
diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go
index f2257973c..4414398b1 100644
--- a/pkg/agent/loop_test.go
+++ b/pkg/agent/loop_test.go
@@ -14,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-*")
@@ -185,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
@@ -260,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
@@ -307,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")
}
@@ -363,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{},
@@ -385,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")
}
@@ -410,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")
}
@@ -537,7 +529,13 @@ type failFirstMockProvider struct {
successResp string
}
-func (m *failFirstMockProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]interface{}) (*providers.LLMResponse, error) {
+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
@@ -602,8 +600,13 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
// Call ProcessDirectWithChannel
// Note: ProcessDirectWithChannel calls processMessage which will execute runLLMIteration
- response, err := al.ProcessDirectWithChannel(context.Background(), "Trigger message", sessionKey, "test", "test-chat")
-
+ 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)
}
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
index 4cf5a6fca..77b846832 100644
--- a/pkg/agent/registry.go
+++ b/pkg/agent/registry.go
@@ -42,7 +42,7 @@ func NewAgentRegistry(
instance := NewAgentInstance(ac, &cfg.Agents.Defaults, cfg, provider)
registry.agents[id] = instance
logger.InfoCF("agent", "Registered agent",
- map[string]interface{}{
+ map[string]any{
"agent_id": id,
"name": ac.Name,
"workspace": instance.Workspace,
diff --git a/pkg/agent/registry_test.go b/pkg/agent/registry_test.go
index f196d7fb7..518bb441f 100644
--- a/pkg/agent/registry_test.go
+++ b/pkg/agent/registry_test.go
@@ -10,7 +10,13 @@ import (
type mockRegistryProvider struct{}
-func (m *mockRegistryProvider) Chat(ctx context.Context, messages []providers.Message, tools []providers.ToolDefinition, model string, options map[string]interface{}) (*providers.LLMResponse, error) {
+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
}
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index dcd91bebd..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)
}
@@ -291,6 +362,12 @@ func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCre
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
}
@@ -300,21 +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 strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") {
- params.Set("originator", "picoclaw")
+
+ 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)
+ }
}
- 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()
}
@@ -327,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)
}
@@ -339,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) {
@@ -396,15 +501,15 @@ func extractAccountID(token string) string {
return accountID
}
- if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok {
+ 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"].([]interface{}); ok {
+ if orgs, ok := claims["organizations"].([]any); ok {
for _, org := range orgs {
- if orgMap, ok := org.(map[string]interface{}); ok {
+ if orgMap, ok := org.(map[string]any); ok {
if accountID, ok := orgMap["id"].(string); ok && accountID != "" {
return accountID
}
@@ -415,7 +520,7 @@ func extractAccountID(token string) string {
return ""
}
-func parseJWTClaims(token string) (map[string]interface{}, error) {
+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")
@@ -434,7 +539,7 @@ func parseJWTClaims(token string) (map[string]interface{}, error) {
return nil, err
}
- var claims map[string]interface{}
+ var claims map[string]any
if err := json.Unmarshal(decoded, &claims); err != nil {
return nil, err
}
diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go
index 5deb17805..0cb589069 100644
--- a/pkg/auth/oauth_test.go
+++ b/pkg/auth/oauth_test.go
@@ -10,7 +10,7 @@ import (
"testing"
)
-func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string {
+func makeJWTForClaims(t *testing.T, claims map[string]any) string {
t.Helper()
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
@@ -89,7 +89,7 @@ func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) {
}
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,
@@ -120,8 +120,8 @@ func TestParseTokenResponse(t *testing.T) {
}
func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
- idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"})
- resp := map[string]interface{}{
+ 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,
@@ -139,9 +139,9 @@ func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) {
}
func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) {
- token := makeJWTForClaims(t, map[string]interface{}{
- "organizations": []interface{}{
- map[string]interface{}{"id": "org_from_orgs"},
+ token := makeJWTForClaims(t, map[string]any{
+ "organizations": []any{
+ map[string]any{"id": "org_from_orgs"},
},
})
@@ -160,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,
@@ -180,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"
}
@@ -201,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,
@@ -240,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,
@@ -290,7 +292,7 @@ func TestRefreshAccessTokenNoRefreshToken(t *testing.T) {
func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- resp := map[string]interface{}{
+ resp := map[string]any{
"access_token": "new-access-token-only",
"expires_in": 3600,
}
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/channels/base.go b/pkg/channels/base.go
index 4925099a3..cd6419ebb 100644
--- a/pkg/channels/base.go
+++ b/pkg/channels/base.go
@@ -17,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,
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 472b51c53..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")
}
@@ -117,7 +138,7 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro
}
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content string) error {
- // 使用传入的 ctx 进行超时控制
+ // Use the passed ctx for timeout control
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
defer cancel()
@@ -138,7 +159,7 @@ func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content strin
}
}
-// appendContent 安全地追加内容到现有文本
+// appendContent safely appends content to existing text
func appendContent(content, suffix string) string {
if content == "" {
return suffix
@@ -155,13 +176,7 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
return
}
- if err := c.session.ChannelTyping(m.ChannelID); err != nil {
- logger.ErrorCF("discord", "Failed to send typing indicator", map[string]any{
- "error": err.Error(),
- })
- }
-
- // 检查白名单,避免为被拒绝的用户下载附件和转录
+ // 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,
@@ -169,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" {
@@ -176,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 {
@@ -203,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{
@@ -243,6 +277,9 @@ 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,
@@ -271,8 +308,66 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag
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 01e570b25..34ce62b20 100644
--- a/pkg/channels/maixcam.go
+++ b/pkg/channels/maixcam.go
@@ -21,10 +21,10 @@ type MaixCamChannel struct {
}
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) {
@@ -49,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,
})
@@ -71,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(),
})
@@ -112,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(),
})
}
@@ -133,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,
})
@@ -170,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,
})
}
@@ -214,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,
@@ -229,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 7f6abc4cb..75edaf49e 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -50,7 +50,7 @@ func (m *Manager) initChannels() error {
logger.DebugC("channels", "Attempting to initialize Telegram channel")
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(),
})
@@ -266,14 +292,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(),
})
@@ -289,13 +315,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 53e82b44d..cee8ad9d3 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -87,14 +87,14 @@ type oneBotSender struct {
}
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 oneBotMessageSegment struct {
- Type string `json:"type"`
- Data map[string]interface{} `json:"data"`
+ Type string `json:"type"`
+ Data map[string]any `json:"data"`
}
func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
@@ -117,13 +117,13 @@ func (c *OneBotChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
func (c *OneBotChannel) setMsgEmojiLike(messageID string, emojiID int, set bool) {
go func() {
- _, err := c.sendAPIRequest("set_msg_emoji_like", map[string]interface{}{
+ _, 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]interface{}{
+ logger.DebugCF("onebot", "Failed to set emoji like", map[string]any{
"message_id": messageID,
"error": err.Error(),
})
@@ -136,14 +136,14 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
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 {
@@ -208,7 +208,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) {
err := conn.WriteMessage(websocket.PingMessage, nil)
c.writeMu.Unlock()
if err != nil {
- logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]interface{}{
+ logger.DebugCF("onebot", "Ping write failed, stopping pinger", map[string]any{
"error": err.Error(),
})
return
@@ -220,7 +220,7 @@ func (c *OneBotChannel) pinger(conn *websocket.Conn) {
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]interface{}{
+ logger.WarnCF("onebot", "Failed to get_login_info", map[string]any{
"error": err.Error(),
})
return
@@ -250,7 +250,7 @@ func (c *OneBotChannel) fetchSelfID() {
}
if uid, err := parseJSONInt64(info.UserID); err == nil && uid > 0 {
atomic.StoreInt64(&c.selfID, uid)
- logger.InfoCF("onebot", "Bot self ID retrieved", map[string]interface{}{
+ logger.InfoCF("onebot", "Bot self ID retrieved", map[string]any{
"self_id": uid,
"nickname": info.Nickname,
})
@@ -258,12 +258,12 @@ func (c *OneBotChannel) fetchSelfID() {
}
}
- logger.WarnCF("onebot", "Could not parse self ID from get_login_info response", map[string]interface{}{
+ 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 interface{}, timeout time.Duration) (json.RawMessage, error) {
+func (c *OneBotChannel) sendAPIRequest(action string, params any, timeout time.Duration) (json.RawMessage, error) {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
@@ -332,7 +332,7 @@ 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 {
@@ -405,7 +405,7 @@ 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
@@ -427,20 +427,20 @@ func (c *OneBotChannel) buildMessageSegments(chatID, content string) []oneBotMes
if msgID, ok := lastMsgID.(string); ok && msgID != "" {
segments = append(segments, oneBotMessageSegment{
Type: "reply",
- Data: map[string]interface{}{"id": msgID},
+ Data: map[string]any{"id": msgID},
})
}
}
segments = append(segments, oneBotMessageSegment{
Type: "text",
- Data: map[string]interface{}{"text": content},
+ Data: map[string]any{"text": content},
})
return segments
}
-func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
+func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, any, error) {
chatID := msg.ChatID
segments := c.buildMessageSegments(chatID, msg.Content)
@@ -458,7 +458,7 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter
if err != nil {
return "", nil, fmt.Errorf("invalid %s in chatID: %s", idKey, chatID)
}
- return action, map[string]interface{}{idKey: id, "message": segments}, nil
+ return action, map[string]any{idKey: id, "message": segments}, nil
}
func (c *OneBotChannel) listen() {
@@ -478,7 +478,7 @@ func (c *OneBotChannel) listen() {
default:
_, 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()
@@ -494,14 +494,14 @@ func (c *OneBotChannel) listen() {
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
}
- logger.DebugCF("onebot", "WebSocket event", map[string]interface{}{
+ logger.DebugCF("onebot", "WebSocket event", map[string]any{
"length": len(message),
"post_type": raw.PostType,
"sub_type": raw.SubType,
@@ -518,7 +518,7 @@ func (c *OneBotChannel) listen() {
default:
}
} else {
- logger.DebugCF("onebot", "Received API response (no waiter)", map[string]interface{}{
+ logger.DebugCF("onebot", "Received API response (no waiter)", map[string]any{
"echo": raw.Echo,
"status": string(raw.Status),
})
@@ -527,7 +527,7 @@ func (c *OneBotChannel) listen() {
}
if isAPIResponse(raw.Status) {
- logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]interface{}{
+ logger.DebugCF("onebot", "Received API response without echo, skipping", map[string]any{
"status": string(raw.Status),
})
continue
@@ -594,7 +594,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
return parseMessageResult{Text: s, IsBotMentioned: mentioned}
}
- var segments []map[string]interface{}
+ var segments []map[string]any
if err := json.Unmarshal(raw, &segments); err != nil {
return parseMessageResult{}
}
@@ -608,7 +608,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
for _, seg := range segments {
segType, _ := seg["type"].(string)
- data, _ := seg["data"].(map[string]interface{})
+ data, _ := seg["data"].(map[string]any)
switch segType {
case "text":
@@ -662,7 +662,7 @@ func (c *OneBotChannel) parseMessageSegments(raw json.RawMessage, selfID int64)
result, err := c.transcriber.Transcribe(tctx, localPath)
tcancel()
if err != nil {
- logger.WarnCF("onebot", "Voice transcription failed", map[string]interface{}{
+ logger.WarnCF("onebot", "Voice transcription failed", map[string]any{
"error": err.Error(),
})
textParts = append(textParts, "[voice (transcription failed)]")
@@ -713,7 +713,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
case "message":
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]interface{}{
+ logger.DebugCF("onebot", "Message rejected by allowlist", map[string]any{
"user_id": userID,
})
return
@@ -722,7 +722,7 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
c.handleMessage(raw)
case "message_sent":
- logger.DebugCF("onebot", "Bot sent message event", map[string]interface{}{
+ logger.DebugCF("onebot", "Bot sent message event", map[string]any{
"message_type": raw.MessageType,
"message_id": parseJSONString(raw.MessageID),
})
@@ -734,18 +734,18 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
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,
})
}
@@ -753,14 +753,14 @@ func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
if raw.MetaEventType == "lifecycle" {
- logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{"sub_type": raw.SubType})
+ 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]interface{}{
+ fields := map[string]any{
"notice_type": raw.NoticeType,
"sub_type": raw.SubType,
"group_id": parseJSONString(raw.GroupID),
@@ -780,7 +780,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
// Parse fields from raw event
userID, err := parseJSONInt64(raw.UserID)
if err != nil {
- logger.WarnCF("onebot", "Failed to parse user_id", map[string]interface{}{
+ logger.WarnCF("onebot", "Failed to parse user_id", map[string]any{
"error": err.Error(),
"raw": string(raw.UserID),
})
@@ -817,7 +817,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
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),
})
@@ -829,7 +829,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
defer func() {
for _, f := range parsed.LocalFiles {
if err := os.Remove(f); err != nil {
- logger.DebugCF("onebot", "Failed to remove temp file", map[string]interface{}{
+ logger.DebugCF("onebot", "Failed to remove temp file", map[string]any{
"path": f,
"error": err.Error(),
})
@@ -839,14 +839,14 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
}
if c.isDuplicate(messageID) {
- logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
+ logger.DebugCF("onebot", "Duplicate message, skipping", map[string]any{
"message_id": messageID,
})
return
}
if content == "" {
- logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
+ logger.DebugCF("onebot", "Received empty message, ignoring", map[string]any{
"message_id": messageID,
})
return
@@ -866,10 +866,14 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
switch raw.MessageType {
case "private":
chatID = "private:" + senderID
+ metadata["peer_kind"] = "direct"
+ metadata["peer_id"] = senderID
case "group":
groupIDStr := strconv.FormatInt(groupID, 10)
chatID = "group:" + groupIDStr
+ metadata["peer_kind"] = "group"
+ metadata["peer_id"] = groupIDStr
metadata["group_id"] = groupIDStr
senderUserID, _ := parseJSONInt64(sender.UserID)
@@ -885,7 +889,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
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": isBotMentioned,
@@ -896,7 +900,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
content = strippedContent
default:
- logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
+ logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]any{
"type": raw.MessageType,
"message_id": messageID,
"user_id": userID,
@@ -904,7 +908,7 @@ func (c *OneBotChannel) handleMessage(raw *oneBotRawEvent) {
return
}
- logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]interface{}{
+ logger.InfoCF("onebot", "Received "+raw.MessageType+" message", map[string]any{
"sender": senderID,
"chat_id": chatID,
"message_id": messageID,
@@ -957,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..e66cac533 100644
--- a/pkg/channels/qq.go
+++ b/pkg/channels/qq.go
@@ -77,7 +77,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
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,
})
@@ -87,7 +87,7 @@ func (c *QQChannel) Start(ctx context.Context) error {
// 在 goroutine 中启动 WebSocket 连接,避免阻塞
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)
@@ -124,7 +124,7 @@ func (c *QQChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
// C2C 消息发送
_, 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
@@ -157,7 +157,7 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
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),
})
@@ -165,6 +165,8 @@ func (c *QQChannel) handleC2CMessage() event.C2CMessageEventHandler {
// 转发到消息总线
metadata := map[string]string{
"message_id": data.ID,
+ "peer_kind": "direct",
+ "peer_id": senderID,
}
c.HandleMessage(senderID, senderID, content, []string{}, metadata)
@@ -197,7 +199,7 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
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),
@@ -207,6 +209,8 @@ func (c *QQChannel) handleGroupATMessage() event.GroupATMessageEventHandler {
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)
diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go
index 0060972ed..f7359cd6d 100644
--- a/pkg/channels/slack.go
+++ b/pkg/channels/slack.go
@@ -75,7 +75,7 @@ func (c *SlackChannel) Start(ctx context.Context) error {
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,
})
@@ -85,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(),
})
}
@@ -140,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,
})
@@ -202,7 +202,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
// 检查白名单,避免为被拒绝的用户下载附件
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
@@ -238,7 +238,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
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(),
})
@@ -261,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)
@@ -293,7 +293,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
"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),
@@ -309,7 +309,7 @@ func (c *SlackChannel) handleAppMention(ev *slackevents.AppMentionEvent) {
}
if !c.IsAllowed(ev.User) {
- logger.DebugCF("slack", "Mention rejected by allowlist", map[string]interface{}{
+ logger.DebugCF("slack", "Mention rejected by allowlist", map[string]any{
"user_id": ev.User,
})
return
@@ -375,7 +375,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
}
if !c.IsAllowed(cmd.UserID) {
- logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]interface{}{
+ logger.DebugCF("slack", "Slash command rejected by allowlist", map[string]any{
"user_id": cmd.UserID,
})
return
@@ -400,7 +400,7 @@ func (c *SlackChannel) handleSlashCommand(event socketmode.Event) {
"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),
@@ -415,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 24b82b557..a0a1c8d0a 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -11,10 +11,9 @@ import (
"sync"
"time"
- th "github.com/mymmrac/telego/telegohandler"
-
"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"
@@ -59,6 +58,13 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
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(telegramCfg.Token, opts...)
@@ -120,7 +126,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
}, 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(),
})
@@ -133,6 +139,7 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return nil
}
+
func (c *TelegramChannel) Stop(ctx context.Context) error {
logger.InfoC("telegram", "Stopping Telegram bot...")
c.setRunning(false)
@@ -175,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 = ""
@@ -203,7 +210,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
// 检查白名单,避免为被拒绝的用户下载附件
if !c.IsAllowed(senderID) {
- logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{
+ logger.DebugCF("telegram", "Message rejected by allowlist", map[string]any{
"user_id": senderID,
})
return nil
@@ -220,7 +227,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
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(),
})
@@ -260,19 +267,19 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
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 = "[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,
})
}
@@ -315,7 +322,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
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),
@@ -324,7 +331,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
// 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(),
})
}
@@ -371,7 +378,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
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 ""
@@ -386,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
@@ -398,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 ""
@@ -456,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("