diff --git a/.gitignore b/.gitignore
index d5691562f..da2ed189c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -65,3 +65,6 @@ cmd/telegram/
!web/backend/dist/
web/backend/dist/*
!web/backend/dist/.gitkeep
+
+
+docker/data
\ No newline at end of file
diff --git a/README.fr.md b/README.fr.md
index bf49ed90a..cbaffc2d1 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -105,6 +105,8 @@ _*Les versions récentes peuvent utiliser 10–20 Mo en raison des fusions rapid
+> 📋 **[Liste de Compatibilité Matérielle](docs/hardware-compatibility.md)** — Voir toutes les cartes testées, du RISC-V à $5 au Raspberry Pi en passant par les téléphones Android. Votre carte n'est pas listée ? Soumettez une PR !
+
## 🦾 Démonstration
### 🛠️ Flux de Travail Standard de l'Assistant
@@ -139,7 +141,7 @@ Donnez une seconde vie à votre téléphone d'il y a dix ans ! Transformez-le en
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot fournit une disposition standard du système de fichiers Linux
```
Puis suivez les instructions de la section « Démarrage Rapide » pour terminer la configuration !
@@ -160,11 +162,15 @@ PicoClaw peut être déployé sur pratiquement n'importe quel appareil Linux !
## 📦 Installation
-### Installer avec un binaire précompilé
+### Télécharger depuis picoclaw.io (Recommandé)
-Téléchargez le binaire pour votre plateforme depuis la page des [Releases](https://github.com/sipeed/picoclaw/releases).
+Visitez **[picoclaw.io](https://picoclaw.io)** — le site officiel détecte automatiquement votre plateforme et propose un téléchargement en un clic. Pas besoin de choisir manuellement une architecture.
-### Installer depuis les sources (dernières fonctionnalités, recommandé pour le développement)
+### Télécharger le binaire précompilé
+
+Vous pouvez aussi télécharger le binaire pour votre plateforme depuis la page [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
+
+### Compiler depuis les sources (pour le développement)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -200,6 +206,7 @@ Pour des guides détaillés, consultez la documentation ci-dessous. Ce README ne
| 🔄 [Spawn & Tâches Asynchrones](docs/fr/spawn-tasks.md) | Tâches rapides, tâches longues avec spawn, orchestration asynchrone de sous-agents |
| 🐛 [Dépannage](docs/fr/troubleshooting.md) | Problèmes courants et solutions |
| 🔧 [Configuration des Outils](docs/fr/tools_configuration.md) | Activation/désactivation par outil, politiques exec |
+| 📋 [Compatibilité Matérielle](docs/hardware-compatibility.md) | Cartes testées, exigences minimales, comment ajouter votre carte |
##
Rejoignez le Réseau Social d'Agents
@@ -225,6 +232,7 @@ Connectez PicoClaw au Réseau Social d'Agents simplement en envoyant un seul mes
| `picoclaw skills install` | Installer une compétence |
| `picoclaw migrate` | Migrer les données des anciennes versions |
| `picoclaw auth login` | S'authentifier auprès des fournisseurs |
+| `picoclaw model` | Voir ou changer le modèle par défaut |
### Tâches Planifiées / Rappels
diff --git a/README.ja.md b/README.ja.md
index 3c017aacd..e5a927505 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -105,6 +105,8 @@ _*最近のバージョンでは急速な機能マージにより 10〜20MB に
+> 📋 **[ハードウェア互換性リスト](docs/hardware-compatibility.md)** — テスト済みの全ボード一覧($5 RISC-V から Raspberry Pi、Android スマートフォンまで)。お使いのボードが未掲載?PR を送ってください!
+
## 🦾 デモンストレーション
### 🛠️ スタンダードアシスタントワークフロー
@@ -139,7 +141,7 @@ _*最近のバージョンでは急速な機能マージにより 10〜20MB に
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイルシステムレイアウトを提供
```
その後「クイックスタート」セクションの手順に従って設定を完了してください!
@@ -160,11 +162,15 @@ PicoClaw はほぼすべての Linux デバイスにデプロイできます!
## 📦 インストール
-### コンパイル済みバイナリでインストール
+### picoclaw.io からダウンロード(推奨)
-[リリースページ](https://github.com/sipeed/picoclaw/releases) からお使いのプラットフォーム用のバイナリをダウンロードしてください。
+**[picoclaw.io](https://picoclaw.io)** にアクセス — 公式サイトがプラットフォームを自動検出し、ワンクリックでダウンロードできます。アーキテクチャを手動で選ぶ必要はありません。
-### ソースからインストール(最新機能、開発向け推奨)
+### プリコンパイル済みバイナリをダウンロード
+
+または、[GitHub Releases](https://github.com/sipeed/picoclaw/releases) ページからプラットフォームに合ったバイナリをダウンロードしてください。
+
+### ソースからビルド(開発用)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -200,6 +206,7 @@ make install
| 🔄 [Spawn & 非同期タスク](docs/ja/spawn-tasks.md) | クイックタスク、spawn による長時間タスク、非同期サブエージェントオーケストレーション |
| 🐛 [トラブルシューティング](docs/ja/troubleshooting.md) | よくある問題と解決策 |
| 🔧 [ツール設定](docs/ja/tools_configuration.md) | ツールごとの有効/無効、exec ポリシー |
+| 📋 [ハードウェア互換性](docs/hardware-compatibility.md) | テスト済みボード、最小要件、ボードの追加方法 |
##
エージェントソーシャルネットワークに参加
@@ -225,6 +232,7 @@ CLI または統合チャットアプリからメッセージを 1 つ送るだ
| `picoclaw skills install` | スキルをインストール |
| `picoclaw migrate` | 旧バージョンからデータを移行 |
| `picoclaw auth login` | プロバイダーへの認証 |
+| `picoclaw model` | デフォルトモデルの表示・切替 |
### スケジュールタスク / リマインダー
diff --git a/README.md b/README.md
index d9785f200..652792d83 100644
--- a/README.md
+++ b/README.md
@@ -105,6 +105,757 @@ _*Recent versions may use 10–20MB due to rapid feature merges. Resource optimi
+> 📋 **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
+
+## 🦾 Demonstration
+
+### 🛠️ Standard Assistant Workflows
+
+
🧩 Full-Stack Engineer |
+ 🗂️ Logging & Planning Management |
+ 🔎 Web Search & Learning |
+
|---|---|---|
|
+
|
+
|
+
| Develop • Deploy • Scale | +Schedule • Automate • Memory | +Discovery • Insights • Trends | +
+
+### 🐜 Innovative Low-Footprint Deploy
+
+PicoClaw can be deployed on almost any Linux device!
+
+- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) E(Ethernet) or W(WiFi6) version, for Minimal Home Assistant
+- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), or $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) for Automated Server Maintenance
+- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) or $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) for Smart Monitoring
+
+
center">
+
+
+ |
+
+ |
+
+
+ |
+
+
+> 📋 **[Hardware Compatibility List](docs/hardware-compatibility.md)** — See all tested boards, from $5 RISC-V to Raspberry Pi to Android phones. Your board not listed? Submit a PR!
+
## 🦾 Demonstration
### 🛠️ Standard Assistant Workflows
@@ -741,4 +1492,52 @@ User Groups:
discord:
+
+##
diff --git a/README.pt-br.md b/README.pt-br.md
index 928e4778c..c1df570a5 100644
--- a/README.pt-br.md
+++ b/README.pt-br.md
@@ -105,6 +105,8 @@ _*Versões recentes podem usar 10–20MB devido a merges rápidos de funcionalid
+> 📋 **[Lista de Compatibilidade de Hardware](docs/hardware-compatibility.md)** — Veja todas as placas testadas, de RISC-V de $5 a Raspberry Pi e telefones Android. Sua placa não está listada? Envie um PR!
+
## 🦾 Demonstração
### 🛠️ Fluxos de Trabalho Padrão do Assistente
@@ -139,7 +141,7 @@ Dê uma segunda vida ao seu celular de dez anos atrás! Transforme-o em um assis
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot fornece um layout padrão do sistema de arquivos Linux
```
Depois siga as instruções na seção "Início Rápido" para completar a configuração!
@@ -160,11 +162,15 @@ O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux!
## 📦 Instalação
-### Instalar com binário pré-compilado
+### Baixar de picoclaw.io (Recomendado)
-Baixe o binário para sua plataforma na página de [Releases](https://github.com/sipeed/picoclaw/releases).
+Visite **[picoclaw.io](https://picoclaw.io)** — o site oficial detecta automaticamente sua plataforma e oferece download com um clique. Sem necessidade de escolher manualmente a arquitetura.
-### Instalar a partir do código-fonte (funcionalidades mais recentes, recomendado para desenvolvimento)
+### Baixar binário pré-compilado
+
+Alternativamente, baixe o binário para sua plataforma na página de [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
+
+### Compilar a partir do código-fonte (para desenvolvimento)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -200,6 +206,7 @@ Para guias detalhados, consulte a documentação abaixo. Este README cobre apena
| 🔄 [Spawn & Tarefas Assíncronas](docs/pt-br/spawn-tasks.md) | Tarefas rápidas, tarefas longas com spawn, orquestração assíncrona de sub-agentes |
| 🐛 [Solução de Problemas](docs/pt-br/troubleshooting.md) | Problemas comuns e soluções |
| 🔧 [Configuração de Ferramentas](docs/pt-br/tools_configuration.md) | Habilitar/desabilitar por ferramenta, políticas de execução |
+| 📋 [Compatibilidade de Hardware](docs/hardware-compatibility.md) | Placas testadas, requisitos mínimos, como adicionar sua placa |
##
+> 📋 **[Danh Sách Tương Thích Phần Cứng](docs/hardware-compatibility.md)** — Xem tất cả các board đã được kiểm tra, từ RISC-V $5 đến Raspberry Pi và điện thoại Android. Board của bạn chưa có? Gửi PR!
+
## 🦾 Demo
### 🛠️ Quy trình trợ lý tiêu chuẩn
@@ -139,7 +141,7 @@ Hãy cho chiếc điện thoại cũ một cuộc sống mới! Biến nó thàn
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot cung cấp bố cục hệ thống tệp Linux tiêu chuẩn
```
Sau đó làm theo hướng dẫn trong phần "Bắt đầu nhanh" để hoàn tất cấu hình!
@@ -160,11 +162,15 @@ PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux!
## 📦 Cài đặt
-### Cài đặt bằng binary biên dịch sẵn
+### Tải từ picoclaw.io (Khuyến nghị)
-Tải file binary cho nền tảng của bạn từ [trang Releases](https://github.com/sipeed/picoclaw/releases).
+Truy cập **[picoclaw.io](https://picoclaw.io)** — trang web chính thức tự động phát hiện nền tảng của bạn và cung cấp tải xuống một cú nhấp. Không cần chọn kiến trúc thủ công.
-### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển)
+### Tải binary đã biên dịch sẵn
+
+Hoặc tải binary cho nền tảng của bạn từ trang [GitHub Releases](https://github.com/sipeed/picoclaw/releases).
+
+### Biên dịch từ mã nguồn (cho phát triển)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -200,6 +206,7 @@ make install
| 🔄 [Spawn & Tác vụ bất đồng bộ](docs/vi/spawn-tasks.md) | Tác vụ nhanh, tác vụ dài với spawn, điều phối sub-agent bất đồng bộ |
| 🐛 [Xử lý sự cố](docs/vi/troubleshooting.md) | Các vấn đề thường gặp và giải pháp |
| 🔧 [Cấu hình Công cụ](docs/vi/tools_configuration.md) | Bật/tắt từng công cụ, chính sách thực thi |
+| 📋 [Tương Thích Phần Cứng](docs/hardware-compatibility.md) | Các board đã kiểm tra, yêu cầu tối thiểu, cách thêm board |
##
+> 📋 **[硬件兼容列表](docs/hardware-compatibility.md)** — 查看所有已测试的板卡,从 $5 RISC-V 到树莓派到安卓手机。你的板卡没在列表中?欢迎提交 PR!
+
## 🦾 演示
### 🛠️ 标准助手工作流
@@ -138,7 +140,7 @@ PicoClaw 可以将你 10 年前的老旧手机废物利用,变身成为你的
wget https://github.com/sipeed/picoclaw/releases/latest/download/picoclaw_Linux_arm64.tar.gz
tar xzf picoclaw_Linux_arm64.tar.gz
pkg install proot
-termux-chroot ./picoclaw onboard
+termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布局
```
然后跟随下面的"快速开始"章节继续配置 PicoClaw 即可使用!
@@ -159,11 +161,15 @@ PicoClaw 几乎可以部署在任何 Linux 设备上!
## 📦 安装
-### 使用预编译二进制文件安装
+### 从 picoclaw.io 下载(推荐)
-从 [Release 页面](https://github.com/sipeed/picoclaw/releases) 下载适用于您平台的二进制文件。
+访问 **[picoclaw.io](https://picoclaw.io)** — 官网自动检测你的平台,提供一键下载,无需手动选择架构。
-### 从源码安装(获取最新特性,开发推荐)
+### 下载预编译二进制文件
+
+也可以从 [GitHub Releases](https://github.com/sipeed/picoclaw/releases) 页面手动下载对应平台的二进制文件。
+
+### 从源码构建(开发用)
```bash
git clone https://github.com/sipeed/picoclaw.git
@@ -199,6 +205,7 @@ make install
| 🔄 [异步任务与 Spawn](docs/zh/spawn-tasks.md) | 快速任务、长任务与 Spawn、异步子 Agent 编排 |
| 🐛 [疑难解答](docs/zh/troubleshooting.md) | 常见问题与解决方案 |
| 🔧 [工具配置](docs/zh/tools_configuration.md) | 工具启用/禁用、执行策略 |
+| 📋 [硬件兼容列表](docs/hardware-compatibility.md) | 已测试板卡、最低要求、如何添加你的板卡 |
## %s", escaped))
+ }
+
+ for i, code := range codeBlocks.codes {
+ escaped := escapeHTML(code)
+ text = strings.ReplaceAll(
+ text,
+ fmt.Sprintf("\x00CB%d\x00", i),
+ fmt.Sprintf("%s", escaped),
+ )
+ }
+
+ return text
+}
+
+type codeBlockMatch struct {
+ text string
+ codes []string
+}
+
+func extractCodeBlocks(text string) codeBlockMatch {
+ matches := reCodeBlock.FindAllStringSubmatch(text, -1)
+
+ codes := make([]string, 0, len(matches))
+ for _, match := range matches {
+ codes = append(codes, match[1])
+ }
+
+ i := 0
+ text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
+ placeholder := fmt.Sprintf("\x00CB%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return codeBlockMatch{text: text, codes: codes}
+}
+
+type inlineCodeMatch struct {
+ text string
+ codes []string
+}
+
+func extractInlineCodes(text string) inlineCodeMatch {
+ matches := reInlineCode.FindAllStringSubmatch(text, -1)
+
+ codes := make([]string, 0, len(matches))
+ for _, match := range matches {
+ codes = append(codes, match[1])
+ }
+
+ i := 0
+ text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
+ placeholder := fmt.Sprintf("\x00IC%d\x00", i)
+ i++
+ return placeholder
+ })
+
+ return inlineCodeMatch{text: text, codes: codes}
+}
+
+func escapeHTML(text string) string {
+ text = strings.ReplaceAll(text, "&", "&")
+ text = strings.ReplaceAll(text, "<", "<")
+ text = strings.ReplaceAll(text, ">", ">")
+ return text
+}
diff --git a/pkg/channels/telegram/render_telegram.go b/pkg/channels/telegram/render_telegram.go
index 7763e3bc7..e38140390 100644
--- a/pkg/channels/telegram/render_telegram.go
+++ b/pkg/channels/telegram/render_telegram.go
@@ -154,12 +154,12 @@ func (r *telegramRenderer) renderCodeBlock(n *ast.CodeBlock) {
} else {
if lang != "" {
r.buf.WriteString("")
} else {
r.buf.WriteString("")
}
- r.buf.WriteString(escapeHTML(content))
+ r.buf.WriteString(escapeHTMLFull(content))
r.buf.WriteString("
\n")
}
}
@@ -260,7 +260,7 @@ func (r *telegramRenderer) renderTable(n *ast.Table) {
r.buf.WriteString("\n```\n")
} else {
r.buf.WriteString("")
- r.buf.WriteString(escapeHTML(mono))
+ r.buf.WriteString(escapeHTMLFull(mono))
r.buf.WriteString("\n")
}
} else {
@@ -380,7 +380,7 @@ func (r *telegramRenderer) renderInlineCode(n *ast.Code) {
r.buf.WriteString("`")
} else {
r.buf.WriteString("")
- r.buf.WriteString(escapeHTML(content))
+ r.buf.WriteString(escapeHTMLFull(content))
r.buf.WriteString("")
}
}
@@ -395,7 +395,7 @@ func (r *telegramRenderer) renderLink(n *ast.Link) {
r.buf.WriteString(")")
} else {
r.buf.WriteString(``)
r.walkChildren(n)
r.buf.WriteString("")
@@ -432,7 +432,7 @@ func (r *telegramRenderer) writeEscaped(text string) {
if r.mdv2 {
r.buf.WriteString(escapeMarkdownV2(text))
} else {
- r.buf.WriteString(escapeHTML(text))
+ r.buf.WriteString(escapeHTMLFull(text))
}
}
@@ -482,8 +482,8 @@ func escapeMarkdownV2(s string) string {
return b.String()
}
-// escapeHTML escapes &, <, > for Telegram HTML mode.
-func escapeHTML(text string) string {
+// escapeHTMLFull escapes &, <, > for Telegram HTML mode.
+func escapeHTMLFull(text string) string {
text = strings.ReplaceAll(text, "&", "&")
text = strings.ReplaceAll(text, "<", "<")
text = strings.ReplaceAll(text, ">", ">")
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index 2e8fe2a75..b19dc0fdb 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -2,6 +2,8 @@ package telegram
import (
"context"
+ "crypto/rand"
+ "encoding/binary"
"fmt"
"io"
"net/http"
@@ -10,6 +12,7 @@ import (
"regexp"
"strconv"
"strings"
+ "sync"
"time"
"github.com/mymmrac/telego"
@@ -26,6 +29,19 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
+var (
+ reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`)
+ reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
+ reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
+ reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
+ reBoldUnder = regexp.MustCompile(`__(.+?)__`)
+ reItalic = regexp.MustCompile(`_([^_]+)_`)
+ reStrike = regexp.MustCompile(`~~(.+?)~~`)
+ reListItem = regexp.MustCompile(`^[-*]\s+`)
+ reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
+ reInlineCode = regexp.MustCompile("`([^`]+)`")
+)
+
// telegramHTTPTimeout is the HTTP client timeout for Telegram API requests.
// Long polling uses Timeout=30s on the API side; the HTTP client timeout
// must be longer to avoid canceling valid long-poll responses.
@@ -387,6 +403,7 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag
return err
}
+// DeleteMessage implements channels.MessageDeleter.
// SendPlaceholder implements channels.PlaceholderCapable.
// It sends a placeholder message (e.g. "Thinking... 💭") that will later be
// edited to the actual response via EditMessage (channels.MessageEditor).
@@ -889,3 +906,107 @@ func (c *TelegramChannel) stripBotMention(content string) string {
content = re.ReplaceAllString(content, "")
return strings.TrimSpace(content)
}
+
+// BeginStream implements channels.StreamingCapable.
+func (c *TelegramChannel) BeginStream(ctx context.Context, chatID string) (channels.Streamer, error) {
+ if !c.config.Channels.Telegram.Streaming.Enabled {
+ return nil, fmt.Errorf("streaming disabled in config")
+ }
+
+ cid, _, err := parseTelegramChatID(chatID)
+ if err != nil {
+ return nil, err
+ }
+
+ streamCfg := c.config.Channels.Telegram.Streaming
+ return &telegramStreamer{
+ bot: c.bot,
+ chatID: cid,
+ draftID: cryptoRandInt(),
+ throttleInterval: time.Duration(streamCfg.ThrottleSeconds) * time.Second,
+ minGrowth: streamCfg.MinGrowthChars,
+ }, nil
+}
+
+// telegramStreamer streams partial LLM output via Telegram's sendMessageDraft API.
+// On first API error (e.g. bot lacks forum mode), it silently degrades: Update
+// becomes a no-op, while Finalize still delivers the final message.
+type telegramStreamer struct {
+ bot *telego.Bot
+ chatID int64
+ draftID int
+ throttleInterval time.Duration
+ minGrowth int
+ lastLen int
+ lastAt time.Time
+ failed bool
+ mu sync.Mutex
+}
+
+func (s *telegramStreamer) Update(ctx context.Context, content string) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if s.failed {
+ return nil
+ }
+
+ // Throttle: skip if not enough time or content has passed
+ now := time.Now()
+ growth := len(content) - s.lastLen
+ if s.lastLen > 0 && now.Sub(s.lastAt) < s.throttleInterval && growth < s.minGrowth {
+ return nil
+ }
+
+ htmlContent := markdownToTelegramHTML(content)
+
+ err := s.bot.SendMessageDraft(ctx, &telego.SendMessageDraftParams{
+ ChatID: s.chatID,
+ DraftID: s.draftID,
+ Text: htmlContent,
+ ParseMode: telego.ModeHTML,
+ })
+ if err != nil {
+ // First error → degrade silently (e.g. no forum mode)
+ logger.WarnCF("telegram", "sendMessageDraft failed, disabling streaming", map[string]any{
+ "error": err.Error(),
+ })
+ s.failed = true
+ return nil // don't propagate — Finalize will still deliver
+ }
+
+ s.lastLen = len(content)
+ s.lastAt = now
+ return nil
+}
+
+func (s *telegramStreamer) Finalize(ctx context.Context, content string) error {
+ htmlContent := markdownToTelegramHTML(content)
+ tgMsg := tu.Message(tu.ID(s.chatID), htmlContent)
+ tgMsg.ParseMode = telego.ModeHTML
+
+ if _, err := s.bot.SendMessage(ctx, tgMsg); err != nil {
+ // Fallback to plain text
+ tgMsg.ParseMode = ""
+ if _, err = s.bot.SendMessage(ctx, tgMsg); err != nil {
+ logger.ErrorCF("telegram", "Finalize failed after HTML and plain-text attempts", map[string]any{
+ "chat_id": s.chatID,
+ "error": err.Error(),
+ "len": len(content),
+ })
+ return fmt.Errorf("telegram finalize: %w", err)
+ }
+ }
+ return nil
+}
+
+func (s *telegramStreamer) Cancel(ctx context.Context) {
+ // Draft auto-expires on Telegram's side; nothing to clean up.
+}
+
+// cryptoRandInt returns a non-zero random int using crypto/rand.
+func cryptoRandInt() int {
+ var b [4]byte
+ _, _ = rand.Read(b[:])
+ return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 5d34408fe..299a166cc 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -321,6 +321,7 @@ type ChannelsConfig struct {
WeComApp WeComAppConfig `json:"wecom_app"`
WeComAIBot WeComAIBotConfig `json:"wecom_aibot"`
Pico PicoConfig `json:"pico"`
+ PicoClient PicoClientConfig `json:"pico_client"`
IRC IRCConfig `json:"irc"`
}
@@ -341,6 +342,12 @@ type PlaceholderConfig struct {
Text string `json:"text,omitempty"`
}
+type StreamingConfig struct {
+ Enabled bool `json:"enabled,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_ENABLED"`
+ ThrottleSeconds int `json:"throttle_seconds,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_THROTTLE_SECONDS"`
+ MinGrowthChars int `json:"min_growth_chars,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_STREAMING_MIN_GROWTH_CHARS"`
+}
+
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
@@ -359,6 +366,7 @@ type TelegramConfig struct {
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ Streaming StreamingConfig `json:"streaming,omitempty"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
SubagentThreadID int `json:"subagent_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"`
@@ -529,6 +537,16 @@ type PicoConfig struct {
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
}
+type PicoClientConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ENABLED"`
+ URL string `json:"url" env:"PICOCLAW_CHANNELS_PICO_CLIENT_URL"`
+ Token string `json:"token" env:"PICOCLAW_CHANNELS_PICO_CLIENT_TOKEN"`
+ SessionID string `json:"session_id,omitempty"`
+ PingInterval int `json:"ping_interval,omitempty"`
+ ReadTimeout int `json:"read_timeout,omitempty"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_PICO_CLIENT_ALLOW_FROM"`
+}
+
type IRCConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_IRC_ENABLED"`
Server string `json:"server" env:"PICOCLAW_CHANNELS_IRC_SERVER"`
diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go
index 5207b47c2..f8eff3660 100644
--- a/pkg/config/defaults.go
+++ b/pkg/config/defaults.go
@@ -62,6 +62,7 @@ func DefaultConfig() *Config {
Enabled: true,
Text: "Thinking... 💭",
},
+ Streaming: StreamingConfig{Enabled: true, ThrottleSeconds: 3, MinGrowthChars: 200},
UseMarkdownV2: false,
},
Feishu: FeishuConfig{
diff --git a/pkg/heartbeat/service.go b/pkg/heartbeat/service.go
index 655c00a61..49b375d8f 100644
--- a/pkg/heartbeat/service.go
+++ b/pkg/heartbeat/service.go
@@ -7,6 +7,7 @@
package heartbeat
import (
+ "context"
"fmt"
"os"
"path/filepath"
@@ -27,6 +28,7 @@ const (
minIntervalMinutes = 5
defaultIntervalMinutes = 30
suppressionTTL = 24 * time.Hour
+ userTasksMarker = "Add your heartbeat tasks below this line:"
)
// HeartbeatHandler is the function type for handling heartbeat.
@@ -325,7 +327,7 @@ func (hs *HeartbeatService) buildPrompt() string {
}
content := string(data)
- if len(content) == 0 {
+ if !heartbeatHasUserTasks(content) {
return ""
}
@@ -462,6 +464,69 @@ Add your heartbeat tasks below this line:
}
}
+func heartbeatHasUserTasks(content string) bool {
+ trimmed := strings.TrimSpace(content)
+ if trimmed == "" {
+ return false
+ }
+
+ markerIdx := strings.Index(content, userTasksMarker)
+ if markerIdx < 0 {
+ return true
+ }
+
+ tasksSection := content[markerIdx+len(userTasksMarker):]
+ for _, line := range strings.Split(tasksSection, "\n") {
+ trimmedLine := strings.TrimSpace(line)
+ if trimmedLine == "" {
+ continue
+ }
+ if strings.HasPrefix(trimmedLine, "#") {
+ continue
+ }
+ return true
+ }
+
+ return false
+}
+
+// sendResponse sends the heartbeat response to the last channel
+func (hs *HeartbeatService) sendResponse(response string) {
+ hs.mu.RLock()
+ msgBus := hs.bus
+ hs.mu.RUnlock()
+
+ if msgBus == nil {
+ hs.logInfof("No message bus configured, heartbeat result not sent")
+ return
+ }
+
+ // Get last channel from state
+ lastChannel := hs.state.GetLastChannel()
+ if lastChannel == "" {
+ hs.logInfof("No last channel recorded, heartbeat result not sent")
+ return
+ }
+
+ platform, userID := hs.parseLastChannel(lastChannel)
+
+ // Skip internal channels that can't receive messages
+ if platform == "" || userID == "" {
+ return
+ }
+
+ pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer pubCancel()
+ msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{
+ Channel: platform,
+ ChatID: userID,
+ Content: response,
+ })
+
+ hs.logInfof("Heartbeat result sent to %s", platform)
+}
+
+
// parseLastChannel parses the last channel string into platform and userID.
// Returns empty strings for invalid or internal channels.
func (hs *HeartbeatService) parseLastChannel(lastChannel string) (platform, userID string) {
diff --git a/pkg/heartbeat/service_test.go b/pkg/heartbeat/service_test.go
index 3b7eeeefb..309b4378f 100644
--- a/pkg/heartbeat/service_test.go
+++ b/pkg/heartbeat/service_test.go
@@ -3,6 +3,7 @@ package heartbeat
import (
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -203,3 +204,47 @@ func TestHeartbeatFilePath(t *testing.T) {
t.Errorf("Expected HEARTBEAT.md at %s, but it doesn't exist", expectedPath)
}
}
+
+func TestBuildPrompt_DefaultTemplateStaysIdle(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ hs := NewHeartbeatService(tmpDir, 30, true)
+ hs.createDefaultHeartbeatTemplate()
+
+ if prompt := hs.buildPrompt(); prompt != "" {
+ t.Fatalf("buildPrompt() = %q, want empty prompt for untouched default template", prompt)
+ }
+}
+
+func TestBuildPrompt_UserTasksAfterMarkerProducePrompt(t *testing.T) {
+ tmpDir, err := os.MkdirTemp("", "heartbeat-test-*")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ hs := NewHeartbeatService(tmpDir, 30, true)
+ hs.createDefaultHeartbeatTemplate()
+
+ path := filepath.Join(tmpDir, "HEARTBEAT.md")
+ data, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("Failed to read HEARTBEAT.md: %v", err)
+ }
+ updated := string(data) + "\n- Check unread Feishu messages\n"
+ if err := os.WriteFile(path, []byte(updated), 0o644); err != nil {
+ t.Fatalf("Failed to update HEARTBEAT.md: %v", err)
+ }
+
+ prompt := hs.buildPrompt()
+ if prompt == "" {
+ t.Fatal("buildPrompt() = empty, want non-empty prompt when user tasks are present")
+ }
+ if !strings.Contains(prompt, "Check unread Feishu messages") {
+ t.Fatalf("prompt = %q, want user task content", prompt)
+ }
+}
diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go
index 372bbe38b..045725a8d 100644
--- a/pkg/identity/identity.go
+++ b/pkg/identity/identity.go
@@ -94,13 +94,18 @@ func MatchAllowed(sender bus.SenderInfo, allowed string) bool {
return false
}
-// isNumeric returns true if s consists entirely of digits.
+// isNumeric returns true if s consists entirely of digits, allowing for an optional leading minus sign
+// (required for Telegram group/channel IDs like -1001234567890).
func isNumeric(s string) bool {
if s == "" {
return false
}
- for _, r := range s {
- if r < '0' || r > '9' {
+ start := 0
+ if s[0] == '-' && len(s) > 1 {
+ start = 1
+ }
+ for i := start; i < len(s); i++ {
+ if s[i] < '0' || s[i] > '9' {
return false
}
}
diff --git a/pkg/identity/identity_test.go b/pkg/identity/identity_test.go
index a588f1484..c60402d19 100644
--- a/pkg/identity/identity_test.go
+++ b/pkg/identity/identity_test.go
@@ -97,6 +97,15 @@ func TestMatchAllowed(t *testing.T) {
allowed: "654321",
want: false,
},
+ {
+ name: "negative numeric ID matches PlatformID",
+ sender: bus.SenderInfo{
+ Platform: "telegram",
+ PlatformID: "-1001234567890",
+ },
+ allowed: "-1001234567890",
+ want: true,
+ },
// Username matching
{
name: "@username matches Username",
@@ -238,6 +247,9 @@ func TestIsNumeric(t *testing.T) {
{"abc", false},
{"12a34", false},
{"telegram", false},
+ {"-1001234567890", true},
+ {"-", false},
+ {"-12a34", false},
}
for _, tt := range tests {
diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go
index a37fafd78..a33fc3124 100644
--- a/pkg/providers/openai_compat/provider.go
+++ b/pkg/providers/openai_compat/provider.go
@@ -177,7 +177,6 @@ func (p *Provider) buildHTTPRequest(
// Use configured maxTokensField if specified, otherwise fallback to model-based detection
fieldName := p.maxTokensField
if fieldName == "" {
- // Fallback: detect from model name for backward compatibility
lowerModel := strings.ToLower(model)
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") ||
strings.Contains(lowerModel, "gpt-5") {
@@ -191,7 +190,6 @@ func (p *Provider) buildHTTPRequest(
if temperature, ok := asFloat(options["temperature"]); ok {
lowerModel := strings.ToLower(model)
- // Kimi k2 models only support temperature=1.
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
requestBody["temperature"] = 1.0
} else {
@@ -208,8 +206,7 @@ func (p *Provider) buildHTTPRequest(
// The key is typically the agent ID -- stable per agent, shared across requests.
// See: https://platform.openai.com/docs/guides/prompt-caching
// Prompt caching is only supported by OpenAI-native endpoints.
- // Non-OpenAI providers (Mistral, Gemini, DeepSeek, etc.) reject unknown
- // fields with 422 errors, so only include it for OpenAI APIs.
+ // Non-OpenAI providers reject unknown fields with 422 errors.
if cacheKey, ok := options["prompt_cache_key"].(string); ok && cacheKey != "" {
if supportsPromptCacheKey(p.apiBase) {
requestBody["prompt_cache_key"] = cacheKey
diff --git a/workspace/skills/agent-browser/SKILL.md b/workspace/skills/agent-browser/SKILL.md
new file mode 100644
index 000000000..43505996d
--- /dev/null
+++ b/workspace/skills/agent-browser/SKILL.md
@@ -0,0 +1,129 @@
+---
+name: agent-browser
+description: "Browser automation via agent-browser CLI. Use when the user needs to navigate websites, fill forms, click buttons, take screenshots, extract data, or test web apps."
+metadata: {"nanobot":{"emoji":"🌐","requires":{"bins":["agent-browser"]},"install":[{"id":"npm","kind":"npm","package":"agent-browser","global":true,"bins":["agent-browser"],"label":"Install agent-browser (npm)"}]}}
+---
+
+# Agent Browser
+
+CLI browser automation via Chrome/Chromium CDP. Install: `npm i -g agent-browser && agent-browser install`.
+
+**Before using this skill**, verify the tool is available by running `which agent-browser`. If the command is not found, tell the user that browser automation requires the `agent-browser` CLI and Chromium, which are only available in the heavy container image. Do not attempt to install it at runtime.
+
+## Core Workflow
+
+1. `agent-browser open ` — navigate
+2. `agent-browser snapshot -i` — get interactive elements with refs (`@e1`, `@e2`, ...)
+3. Interact using refs — `click @e1`, `fill @e2 "text"`
+4. Re-snapshot after any navigation or DOM change — refs are invalidated
+
+```bash
+agent-browser open https://example.com/form
+agent-browser snapshot -i
+# @e1 [input] "Email", @e2 [input] "Password", @e3 [button] "Submit"
+agent-browser fill @e1 "user@example.com"
+agent-browser fill @e2 "secret"
+agent-browser click @e3
+agent-browser wait --load networkidle
+agent-browser snapshot -i
+```
+
+Chain commands with `&&` when you don't need intermediate output:
+```bash
+agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
+```
+
+## Commands
+
+```bash
+# Navigation
+agent-browser open
+agent-browser close
+
+# Snapshot
+agent-browser snapshot -i # Interactive elements with refs
+agent-browser snapshot -s "#selector" # Scope to CSS selector
+
+# Interaction (use @refs from snapshot)
+agent-browser click @e1
+agent-browser fill @e2 "text" # Clear + type
+agent-browser type @e2 "text" # Type without clearing
+agent-browser select @e1 "option"
+agent-browser check @e1
+agent-browser press Enter
+agent-browser scroll down 500
+
+# Get info
+agent-browser get text @e1
+agent-browser get url
+agent-browser get title
+
+# Wait
+agent-browser wait @e1 # Wait for element
+agent-browser wait --load networkidle # Wait for network idle
+agent-browser wait --url "**/dashboard" # Wait for URL pattern
+agent-browser wait --text "Welcome" # Wait for text
+agent-browser wait 2000 # Wait ms
+
+# Capture
+agent-browser screenshot # Screenshot to temp dir
+agent-browser screenshot --full # Full page
+agent-browser screenshot --annotate # With numbered element labels ([N] -> @eN)
+agent-browser pdf output.pdf
+
+# Semantic locators (when refs unavailable)
+agent-browser find text "Sign In" click
+agent-browser find label "Email" fill "user@test.com"
+agent-browser find role button click --name "Submit"
+```
+
+## Authentication
+
+```bash
+# Option 1: Import from user's running Chrome
+agent-browser --auto-connect state save ./auth.json
+agent-browser --state ./auth.json open https://app.example.com
+
+# Option 2: Persistent profile
+agent-browser --profile ~/.myapp open https://app.example.com/login
+# ... login once, all future runs are authenticated
+
+# Option 3: Session name (auto-save/restore)
+agent-browser --session-name myapp open https://app.example.com/login
+# ... login, close, next run state is restored
+
+# Option 4: State file
+agent-browser state save auth.json
+agent-browser state load auth.json
+```
+
+## Iframes
+
+Iframe content is inlined in snapshots. Interact with iframe refs directly — no frame switch needed.
+
+## Parallel Sessions
+
+```bash
+agent-browser --session s1 open https://site-a.com
+agent-browser --session s2 open https://site-b.com
+agent-browser session list
+```
+
+## JavaScript Eval
+
+```bash
+agent-browser eval 'document.title'
+
+# Complex JS — use --stdin to avoid shell quoting issues
+agent-browser eval --stdin <<'EVALEOF'
+JSON.stringify(Array.from(document.querySelectorAll("a")).map(a => a.href))
+EVALEOF
+```
+
+## Cleanup
+
+Always close sessions when done:
+```bash
+agent-browser close
+agent-browser --session s1 close
+```