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

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

+
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/channel.go b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
index 16b7d053b..2f28af123 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/channel.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/channel.go
@@ -12,7 +12,6 @@ import (
func (s *appState) buildChannelMenuItems() []MenuItem {
return []MenuItem{
- {Label: "Back", Description: "Return to main menu", Action: func() { s.pop() }},
channelItem(
"Telegram",
"Telegram bot settings",
@@ -101,10 +100,6 @@ func (s *appState) channelMenu() tview.Primitive {
s.pop()
return nil
}
- if event.Rune() == 'q' {
- s.pop()
- return nil
- }
return event
})
return menu
diff --git a/cmd/picoclaw-launcher-tui/internal/ui/style.go b/cmd/picoclaw-launcher-tui/internal/ui/style.go
index 68cdd60b9..da3c3526d 100644
--- a/cmd/picoclaw-launcher-tui/internal/ui/style.go
+++ b/cmd/picoclaw-launcher-tui/internal/ui/style.go
@@ -41,3 +41,15 @@ func bannerView() *tview.TextView {
text.SetBorder(false)
return text
}
+
+const footerText = "Esc: Back/Exit | Enter: Enter | ←↓↑→ : Move | Space: Select | Tab/Shift+Tab: Switch"
+
+func footerView() *tview.TextView {
+ text := tview.NewTextView()
+ text.SetTextAlign(tview.AlignCenter)
+ text.SetText(footerText)
+ text.SetBackgroundColor(tview.Styles.MoreContrastBackgroundColor)
+ text.SetTextColor(tview.Styles.PrimaryTextColor)
+ text.SetBorder(false)
+ return text
+}
diff --git a/cmd/picoclaw/internal/agent/command.go b/cmd/picoclaw/internal/agent/command.go
index 8939ddb2c..47262fc85 100644
--- a/cmd/picoclaw/internal/agent/command.go
+++ b/cmd/picoclaw/internal/agent/command.go
@@ -6,11 +6,10 @@ import (
func NewAgentCommand() *cobra.Command {
var (
- message string
- sessionKey string
- model string
- debug bool
- orchestrationEnabled bool
+ message string
+ sessionKey string
+ model string
+ debug bool
)
cmd := &cobra.Command{
@@ -18,7 +17,7 @@ func NewAgentCommand() *cobra.Command {
Short: "Interact with the agent directly",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
- return agentCmd(message, sessionKey, model, debug, orchestrationEnabled)
+ return agentCmd(message, sessionKey, model, debug)
},
}
@@ -26,7 +25,6 @@ func NewAgentCommand() *cobra.Command {
cmd.Flags().StringVarP(&message, "message", "m", "", "Send a single message (non-interactive mode)")
cmd.Flags().StringVarP(&sessionKey, "session", "s", "cli:default", "Session key")
cmd.Flags().StringVarP(&model, "model", "", "", "Model to use")
- cmd.Flags().BoolVar(&orchestrationEnabled, "orchestration", false, "Enable orchestration mode")
return cmd
}
diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go
index fab1bc54c..a995945d2 100644
--- a/cmd/picoclaw/internal/agent/helpers.go
+++ b/cmd/picoclaw/internal/agent/helpers.go
@@ -18,7 +18,7 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
)
-func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled bool) error {
+func agentCmd(message, sessionKey, model string, debug bool) error {
if sessionKey == "" {
sessionKey = "cli:default"
}
@@ -37,10 +37,6 @@ func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled boo
cfg.Agents.Defaults.ModelName = model
}
- if orchestrationEnabled {
- cfg.Agents.Defaults.Orchestration = true
- }
-
provider, modelID, err := providers.CreateProvider(cfg)
if err != nil {
return fmt.Errorf("error creating provider: %w", err)
@@ -54,6 +50,7 @@ func agentCmd(message, sessionKey, model string, debug, orchestrationEnabled boo
msgBus := bus.NewMessageBus()
defer msgBus.Close()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
+ defer agentLoop.Close()
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go
index a0a229167..4bf132685 100644
--- a/cmd/picoclaw/internal/auth/helpers.go
+++ b/cmd/picoclaw/internal/auth/helpers.go
@@ -72,14 +72,14 @@ func authLoginOpenAI(useDeviceCode bool) error {
// If no openai in ModelList, add it
if !foundOpenAI {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
- ModelName: "gpt-5.2",
- Model: "openai/gpt-5.2",
+ ModelName: "gpt-5.4",
+ Model: "openai/gpt-5.4",
AuthMethod: "oauth",
})
}
// Update default model to use OpenAI
- appCfg.Agents.Defaults.ModelName = "gpt-5.2"
+ appCfg.Agents.Defaults.ModelName = "gpt-5.4"
if err = config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
@@ -90,7 +90,7 @@ func authLoginOpenAI(useDeviceCode bool) error {
if cred.AccountID != "" {
fmt.Printf("Account: %s\n", cred.AccountID)
}
- fmt.Println("Default model set to: gpt-5.2")
+ fmt.Println("Default model set to: gpt-5.4")
return nil
}
@@ -318,13 +318,13 @@ func authLoginPasteToken(provider string) error {
}
if !found {
appCfg.ModelList = append(appCfg.ModelList, config.ModelConfig{
- ModelName: "gpt-5.2",
- Model: "openai/gpt-5.2",
+ ModelName: "gpt-5.4",
+ Model: "openai/gpt-5.4",
AuthMethod: "token",
})
}
// Update default model
- appCfg.Agents.Defaults.ModelName = "gpt-5.2"
+ appCfg.Agents.Defaults.ModelName = "gpt-5.4"
}
if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil {
return fmt.Errorf("could not update config: %w", err)
diff --git a/cmd/picoclaw/internal/gateway/command.go b/cmd/picoclaw/internal/gateway/command.go
index 35976ff10..bfa69f072 100644
--- a/cmd/picoclaw/internal/gateway/command.go
+++ b/cmd/picoclaw/internal/gateway/command.go
@@ -1,29 +1,42 @@
package gateway
import (
+ "fmt"
+
"github.com/spf13/cobra"
+
+ "github.com/sipeed/picoclaw/pkg/logger"
+ "github.com/sipeed/picoclaw/pkg/utils"
)
func NewGatewayCommand() *cobra.Command {
- var (
- debug bool
- orchestration bool
- enableStats bool
- )
+ var debug bool
+ var noTruncate bool
cmd := &cobra.Command{
Use: "gateway",
Aliases: []string{"g"},
Short: "Start picoclaw gateway",
Args: cobra.NoArgs,
+ PreRunE: func(_ *cobra.Command, _ []string) error {
+ if noTruncate && !debug {
+ return fmt.Errorf("the --no-truncate option can only be used in conjunction with --debug (-d)")
+ }
+
+ if noTruncate {
+ utils.SetDisableTruncation(true)
+ logger.Info("String truncation is globally disabled via 'no-truncate' flag")
+ }
+
+ return nil
+ },
RunE: func(_ *cobra.Command, _ []string) error {
- return gatewayCmd(debug, orchestration, enableStats)
+ return gatewayCmd(debug)
},
}
cmd.Flags().BoolVarP(&debug, "debug", "d", false, "Enable debug logging")
- cmd.Flags().BoolVar(&orchestration, "orchestration", false, "Enable subagent orchestration")
- cmd.Flags().BoolVar(&enableStats, "stats", false, "Enable stats tracking")
+ cmd.Flags().BoolVarP(&noTruncate, "no-truncate", "T", false, "Disable string truncation in debug logs")
return cmd
}
diff --git a/cmd/picoclaw/internal/helpers.go b/cmd/picoclaw/internal/helpers.go
index f81d7013d..e04bccffb 100644
--- a/cmd/picoclaw/internal/helpers.go
+++ b/cmd/picoclaw/internal/helpers.go
@@ -1,23 +1,14 @@
package internal
import (
- "fmt"
"os"
"path/filepath"
- "runtime"
"github.com/sipeed/picoclaw/pkg/config"
)
const Logo = "🦞"
-var (
- version = "dev"
- gitCommit string
- buildTime string
- goVersion string
-)
-
// GetPicoclawHome returns the picoclaw home directory.
// Priority: $PICOCLAW_HOME > ~/.picoclaw
func GetPicoclawHome() string {
@@ -40,25 +31,19 @@ func LoadConfig() (*config.Config, error) {
}
// FormatVersion returns the version string with optional git commit
+// Deprecated: Use pkg/config.FormatVersion instead
func FormatVersion() string {
- v := version
- if gitCommit != "" {
- v += fmt.Sprintf(" (git: %s)", gitCommit)
- }
- return v
+ return config.FormatVersion()
}
// FormatBuildInfo returns build time and go version info
+// Deprecated: Use pkg/config.FormatBuildInfo instead
func FormatBuildInfo() (string, string) {
- build := buildTime
- goVer := goVersion
- if goVer == "" {
- goVer = runtime.Version()
- }
- return build, goVer
+ return config.FormatBuildInfo()
}
// GetVersion returns the version string
+// Deprecated: Use pkg/config.GetVersion instead
func GetVersion() string {
- return version
+ return config.GetVersion()
}
diff --git a/cmd/picoclaw/internal/onboard/command.go b/cmd/picoclaw/internal/onboard/command.go
index e89c15ab7..ec1012959 100644
--- a/cmd/picoclaw/internal/onboard/command.go
+++ b/cmd/picoclaw/internal/onboard/command.go
@@ -1,6 +1,14 @@
package onboard
-import "github.com/spf13/cobra"
+import (
+ "embed"
+
+ "github.com/spf13/cobra"
+)
+
+//go:generate cp -r ../../../../workspace .
+//go:embed workspace
+var embeddedFiles embed.FS
func NewOnboardCommand() *cobra.Command {
cmd := &cobra.Command{
diff --git a/cmd/picoclaw/internal/status/helpers.go b/cmd/picoclaw/internal/status/helpers.go
index ab28f4885..dd7063fe6 100644
--- a/cmd/picoclaw/internal/status/helpers.go
+++ b/cmd/picoclaw/internal/status/helpers.go
@@ -6,6 +6,7 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
"github.com/sipeed/picoclaw/pkg/auth"
+ "github.com/sipeed/picoclaw/pkg/config"
)
func statusCmd() {
@@ -18,8 +19,8 @@ func statusCmd() {
configPath := internal.GetConfigPath()
fmt.Printf("%s picoclaw Status\n", internal.Logo)
- fmt.Printf("Version: %s\n", internal.FormatVersion())
- build, _ := internal.FormatBuildInfo()
+ fmt.Printf("Version: %s\n", config.FormatVersion())
+ build, _ := config.FormatBuildInfo()
if build != "" {
fmt.Printf("Build: %s\n", build)
}
diff --git a/cmd/picoclaw/internal/version/command.go b/cmd/picoclaw/internal/version/command.go
index 1cf686671..71c7dd2f8 100644
--- a/cmd/picoclaw/internal/version/command.go
+++ b/cmd/picoclaw/internal/version/command.go
@@ -6,6 +6,7 @@ import (
"github.com/spf13/cobra"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal"
+ "github.com/sipeed/picoclaw/pkg/config"
)
func NewVersionCommand() *cobra.Command {
@@ -22,8 +23,8 @@ func NewVersionCommand() *cobra.Command {
}
func printVersion() {
- fmt.Printf("%s picoclaw %s\n", internal.Logo, internal.FormatVersion())
- build, goVer := internal.FormatBuildInfo()
+ fmt.Printf("%s picoclaw %s\n", internal.Logo, config.FormatVersion())
+ build, goVer := config.FormatBuildInfo()
if build != "" {
fmt.Printf(" Build: %s\n", build)
}
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index d9263462e..b82475905 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -22,15 +22,16 @@ import (
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/skills"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/status"
"github.com/sipeed/picoclaw/cmd/picoclaw/internal/version"
+ "github.com/sipeed/picoclaw/pkg/config"
)
func NewPicoclawCommand() *cobra.Command {
- short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, internal.GetVersion())
+ short := fmt.Sprintf("%s picoclaw - Personal AI Assistant v%s\n\n", internal.Logo, config.GetVersion())
cmd := &cobra.Command{
Use: "picoclaw",
Short: short,
- Example: "picoclaw list",
+ Example: "picoclaw version",
}
cmd.AddCommand(
diff --git a/config/config.example.json b/config/config.example.json
index 0e2cae8e5..b259df6f6 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -3,7 +3,7 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
- "model_name": "gpt4",
+ "model_name": "gpt-5.4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20,
@@ -13,8 +13,8 @@
},
"model_list": [
{
- "model_name": "gpt4",
- "model": "openai/gpt-5.2",
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
},
@@ -36,14 +36,19 @@
"api_key": "sk-your-deepseek-key"
},
{
- "model_name": "loadbalanced-gpt4",
- "model": "openai/gpt-5.2",
+ "model_name": "longcat",
+ "model": "longcat/LongCat-Flash-Thinking",
+ "api_key": "your-longcat-api-key"
+ },
+ {
+ "model_name": "loadbalanced-gpt-5.4",
+ "model": "openai/gpt-5.4",
"api_key": "sk-key1",
"api_base": "https://api1.example.com/v1"
},
{
- "model_name": "loadbalanced-gpt4",
- "model": "openai/gpt-5.2",
+ "model_name": "loadbalanced-gpt-5.4",
+ "model": "openai/gpt-5.4",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
}
@@ -194,8 +199,13 @@
"nickserv_password": "",
"sasl_user": "",
"sasl_password": "",
- "channels": ["#mychannel"],
- "request_caps": ["server-time", "message-tags"],
+ "channels": [
+ "#mychannel"
+ ],
+ "request_caps": [
+ "server-time",
+ "message-tags"
+ ],
"allow_from": [],
"group_trigger": {
"mention_only": true
@@ -269,6 +279,10 @@
"avian": {
"api_key": "",
"api_base": "https://api.avian.io/v1"
+ },
+ "longcat": {
+ "api_key": "",
+ "api_base": "https://api.longcat.chat/openai"
}
},
"tools": {
@@ -279,6 +293,9 @@
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
+ "api_keys": [
+ "YOUR_BRAVE_API_KEY"
+ ],
"max_results": 5
},
"tavily": {
@@ -293,7 +310,10 @@
},
"perplexity": {
"enabled": false,
- "api_key": "",
+ "api_key": "pplx-xxx",
+ "api_keys": [
+ "pplx-xxx"
+ ],
"max_results": 5
},
"searxng": {
@@ -316,6 +336,13 @@
},
"mcp": {
"enabled": false,
+ "discovery": {
+ "enabled": false,
+ "ttl": 5,
+ "max_search_results": 5,
+ "use_bm25": true,
+ "use_regex": false
+ },
"servers": {
"context7": {
"enabled": false,
@@ -459,6 +486,9 @@
"enabled": false,
"monitor_usb": true
},
+ "voice": {
+ "echo_transcription": false
+ },
"gateway": {
"host": "127.0.0.1",
"port": 18790
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 9ec71abab..b26cf4199 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -19,7 +19,7 @@ services:
# ─────────────────────────────────────────────
# PicoClaw Gateway (Long-running Bot)
- # docker compose -f docker/docker-compose.yml up picoclaw-gateway
+ # docker compose -f docker/docker-compose.yml --profile gateway up
# ─────────────────────────────────────────────
picoclaw-gateway:
image: docker.io/sipeed/picoclaw:latest
@@ -32,3 +32,21 @@ services:
# - "host.docker.internal:host-gateway"
volumes:
- ./data:/root/.picoclaw
+
+ # ─────────────────────────────────────────────
+ # PicoClaw Launcher (Web Console + Gateway)
+ # docker compose -f docker/docker-compose.yml --profile launcher up
+ # ─────────────────────────────────────────────
+ picoclaw-launcher:
+ image: docker.io/sipeed/picoclaw:launcher
+ container_name: picoclaw-launcher
+ restart: on-failure
+ profiles:
+ - launcher
+ environment:
+ - PICOCLAW_GATEWAY_HOST=0.0.0.0
+ ports:
+ - "127.0.0.1:18800:18800"
+ - "127.0.0.1:18790:18790"
+ volumes:
+ - ./data:/root/.picoclaw
diff --git a/docs/channels/matrix/README.md b/docs/channels/matrix/README.md
index c213aa80b..233f5c0a3 100644
--- a/docs/channels/matrix/README.md
+++ b/docs/channels/matrix/README.md
@@ -22,7 +22,8 @@ Add this to `config.json`:
"enabled": true,
"text": "Thinking..."
},
- "reasoning_channel_id": ""
+ "reasoning_channel_id": "",
+ "message_format": "richtext"
}
}
}
@@ -42,10 +43,12 @@ Add this to `config.json`:
| group_trigger | object | No | Group trigger strategy (`mention_only` / `prefixes`) |
| placeholder | object | No | Placeholder message config |
| reasoning_channel_id | string | No | Target channel for reasoning output |
+| message_format | string | No | Output format: `"richtext"` (default) renders markdown as HTML; `"plain"` sends plain text only |
## 3. Currently Supported
-- Text message send/receive
+- Text message send/receive with markdown rendering (bold, italic, headers, code blocks, etc.)
+- Configurable message format (`richtext` / `plain`)
- Incoming image/audio/video/file download (MediaStore first, local path fallback)
- Incoming audio normalization into existing transcription flow (`[audio: ...]`)
- Outgoing image/audio/video/file upload and send
diff --git a/docs/design/provider-refactoring.md b/docs/design/provider-refactoring.md
index a214d9857..38f379c50 100644
--- a/docs/design/provider-refactoring.md
+++ b/docs/design/provider-refactoring.md
@@ -66,7 +66,7 @@ Problem: Agent needs to know both `provider` and `model`, adding complexity.
Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
1. **Model-centric**: Users care about models, not providers
-2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.2`, `anthropic/claude-sonnet-4.6`
+2. **Protocol prefix**: Use `protocol/model_name` format, e.g., `openai/gpt-5.4`, `anthropic/claude-sonnet-4.6`
3. **Configuration-driven**: Adding new Providers only requires config changes, no code changes
### 2.2 New Configuration Structure
@@ -81,8 +81,8 @@ Inspired by [LiteLLM](https://docs.litellm.ai/docs/proxy/configs) design:
"api_key": "sk-xxx"
},
{
- "model_name": "gpt-5.2",
- "model": "openai/gpt-5.2",
+ "model_name": "gpt-5.4",
+ "model": "openai/gpt-5.4",
"api_key": "sk-xxx"
},
{
@@ -128,7 +128,7 @@ type Config struct {
type ModelConfig struct {
// Required
ModelName string `json:"model_name"` // user-facing name (alias)
- Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.2
+ Model string `json:"model"` // protocol/model, e.g., openai/gpt-5.4
// Common config
APIBase string `json:"api_base,omitempty"`
@@ -180,7 +180,7 @@ Identify protocol via prefix in `model` field:
"model": "deepseek-chat"
},
"coder": {
- "model": "gpt-5.2",
+ "model": "gpt-5.4",
"system_prompt": "You are a coding assistant..."
},
"translator": {
@@ -200,7 +200,7 @@ Each Agent only needs to specify `model` (corresponds to `model_name` in `model_
model_list:
- model_name: gpt-4o
litellm_params:
- model: openai/gpt-5.2
+ model: openai/gpt-5.4
api_key: xxx
- model_name: my-custom
litellm_params:
diff --git a/docs/migration/model-list-migration.md b/docs/migration/model-list-migration.md
index 0d4af719c..eed228d4d 100644
--- a/docs/migration/model-list-migration.md
+++ b/docs/migration/model-list-migration.md
@@ -40,7 +40,7 @@ The new `model_list` configuration offers several advantages:
"agents": {
"defaults": {
"provider": "openai",
- "model": "gpt-5.2"
+ "model": "gpt-5.4"
}
}
}
@@ -53,7 +53,7 @@ The new `model_list` configuration offers several advantages:
"model_list": [
{
"model_name": "gpt4",
- "model": "openai/gpt-5.2",
+ "model": "openai/gpt-5.4",
"api_key": "sk-your-openai-key",
"api_base": "https://api.openai.com/v1"
},
@@ -82,7 +82,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| Prefix | Description | Example |
|--------|-------------|---------|
-| `openai/` | OpenAI API (default) | `openai/gpt-5.2` |
+| `openai/` | OpenAI API (default) | `openai/gpt-5.4` |
| `anthropic/` | Anthropic API | `anthropic/claude-opus-4` |
| `antigravity/` | Google via Antigravity OAuth | `antigravity/gemini-2.0-flash` |
| `gemini/` | Google Gemini API | `gemini/gemini-2.0-flash-exp` |
@@ -109,7 +109,7 @@ The `model` field uses a protocol prefix format: `[protocol/]model-identifier`
| Field | Required | Description |
|-------|----------|-------------|
| `model_name` | Yes | User-facing alias for the model |
-| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.2`) |
+| `model` | Yes | Protocol and model identifier (e.g., `openai/gpt-5.4`) |
| `api_base` | No | API endpoint URL |
| `api_key` | No* | API authentication key |
| `proxy` | No | HTTP proxy URL |
@@ -130,19 +130,19 @@ Configure multiple endpoints for the same model to distribute load:
"model_list": [
{
"model_name": "gpt4",
- "model": "openai/gpt-5.2",
+ "model": "openai/gpt-5.4",
"api_key": "sk-key1",
"api_base": "https://api1.example.com/v1"
},
{
"model_name": "gpt4",
- "model": "openai/gpt-5.2",
+ "model": "openai/gpt-5.4",
"api_key": "sk-key2",
"api_base": "https://api2.example.com/v1"
},
{
"model_name": "gpt4",
- "model": "openai/gpt-5.2",
+ "model": "openai/gpt-5.4",
"api_key": "sk-key3",
"api_base": "https://api3.example.com/v1"
}
diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md
index e64a3a107..8c8eb31f0 100644
--- a/docs/tools_configuration.md
+++ b/docs/tools_configuration.md
@@ -7,11 +7,21 @@ PicoClaw's tools configuration is located in the `tools` field of `config.json`.
```json
{
"tools": {
- "web": { ... },
- "mcp": { ... },
- "exec": { ... },
- "cron": { ... },
- "skills": { ... }
+ "web": {
+ ...
+ },
+ "mcp": {
+ ...
+ },
+ "exec": {
+ ...
+ },
+ "cron": {
+ ...
+ },
+ "skills": {
+ ...
+ }
}
}
```
@@ -23,7 +33,7 @@ Web tools are used for web search and fetching.
### Brave
| Config | Type | Default | Description |
-| ------------- | ------ | ------- | ------------------------- |
+|---------------|--------|---------|---------------------------|
| `enabled` | bool | false | Enable Brave search |
| `api_key` | string | - | Brave Search API key |
| `max_results` | int | 5 | Maximum number of results |
@@ -31,14 +41,14 @@ Web tools are used for web search and fetching.
### DuckDuckGo
| Config | Type | Default | Description |
-| ------------- | ---- | ------- | ------------------------- |
+|---------------|------|---------|---------------------------|
| `enabled` | bool | true | Enable DuckDuckGo search |
| `max_results` | int | 5 | Maximum number of results |
### Perplexity
| Config | Type | Default | Description |
-| ------------- | ------ | ------- | ------------------------- |
+|---------------|--------|---------|---------------------------|
| `enabled` | bool | false | Enable Perplexity search |
| `api_key` | string | - | Perplexity API key |
| `max_results` | int | 5 | Maximum number of results |
@@ -48,7 +58,7 @@ Web tools are used for web search and fetching.
The exec tool is used to execute shell commands.
| Config | Type | Default | Description |
-| ---------------------- | ----- | ------- | ------------------------------------------ |
+|------------------------|-------|---------|--------------------------------------------|
| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking |
| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) |
@@ -81,7 +91,10 @@ By default, PicoClaw blocks the following dangerous commands:
"tools": {
"exec": {
"enable_deny_patterns": true,
- "custom_deny_patterns": ["\\brm\\s+-r\\b", "\\bkillall\\s+python"]
+ "custom_deny_patterns": [
+ "\\brm\\s+-r\\b",
+ "\\bkillall\\s+python"
+ ]
}
}
}
@@ -92,24 +105,47 @@ By default, PicoClaw blocks the following dangerous commands:
The cron tool is used for scheduling periodic tasks.
| Config | Type | Default | Description |
-| ---------------------- | ---- | ------- | ---------------------------------------------- |
+|------------------------|------|---------|------------------------------------------------|
| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit |
## MCP Tool
The MCP tool enables integration with external Model Context Protocol servers.
+### Tool Discovery (Lazy Loading)
+
+When connecting to multiple MCP servers, exposing hundreds of tools simultaneously can exhaust the LLM's context window
+and increase API costs. The **Discovery** feature solves this by keeping MCP tools *hidden* by default.
+
+Instead of loading all tools, the LLM is provided with a lightweight search tool (using BM25 keyword matching or Regex).
+When the LLM needs a specific capability, it searches the hidden library. Matching tools are then temporarily "unlocked"
+and injected into the context for a configured number of turns (`ttl`).
+
### Global Config
-| Config | Type | Default | Description |
-| --------- | ------ | ------- | ----------------------------------- |
-| `enabled` | bool | false | Enable MCP integration globally |
-| `servers` | object | `{}` | Map of server name to server config |
+| Config | Type | Default | Description |
+|-------------|--------|---------|----------------------------------------------|
+| `enabled` | bool | false | Enable MCP integration globally |
+| `discovery` | object | `{}` | Configuration for Tool Discovery (see below) |
+| `servers` | object | `{}` | Map of server name to server config |
+
+### Discovery Config (`discovery`)
+
+| Config | Type | Default | Description |
+|----------------------|------|---------|-----------------------------------------------------------------------------------------------------------------------------------|
+| `enabled` | bool | false | If true, MCP tools are hidden and loaded on-demand via search. If false, all tools are loaded |
+| `ttl` | int | 5 | Number of conversational turns a discovered tool remains unlocked |
+| `max_search_results` | int | 5 | Maximum number of tools returned per search query |
+| `use_bm25` | bool | true | Enable the natural language/keyword search tool (`tool_search_tool_bm25`). **Warning**: consumes more resources than regex search |
+| `use_regex` | bool | false | Enable the regex pattern search tool (`tool_search_tool_regex`) |
+
+> **Note:** If `discovery.enabled` is `true`, you MUST enable at least one search engine (`use_bm25` or `use_regex`),
+> otherwise the application will fail to start.
### Per-Server Config
| Config | Type | Required | Description |
-| ---------- | ------ | -------- | ------------------------------------------ |
+|------------|--------|----------|--------------------------------------------|
| `enabled` | bool | yes | Enable this MCP server |
| `type` | string | no | Transport type: `stdio`, `sse`, `http` |
| `command` | string | stdio | Executable command for stdio transport |
@@ -122,8 +158,8 @@ The MCP tool enables integration with external Model Context Protocol servers.
### Transport Behavior
- If `type` is omitted, transport is auto-detected:
- - `url` is set → `sse`
- - `command` is set → `stdio`
+ - `url` is set → `sse`
+ - `command` is set → `stdio`
- `http` and `sse` both use `url` + optional `headers`.
- `env` and `env_file` are only applied to `stdio` servers.
@@ -140,7 +176,11 @@ The MCP tool enables integration with external Model Context Protocol servers.
"filesystem": {
"enabled": true,
"command": "npx",
- "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-filesystem",
+ "/tmp"
+ ]
}
}
}
@@ -170,20 +210,76 @@ The MCP tool enables integration with external Model Context Protocol servers.
}
```
+#### 3) Massive MCP setup with Tool Discovery enabled
+
+*In this example, the LLM will only see the `tool_search_tool_bm25`. It will search and unlock Github or Postgres tools
+dynamically only when requested by the user.*
+
+```json
+{
+ "tools": {
+ "mcp": {
+ "enabled": true,
+ "discovery": {
+ "enabled": true,
+ "ttl": 5,
+ "max_search_results": 5,
+ "use_bm25": true,
+ "use_regex": false
+ },
+ "servers": {
+ "github": {
+ "enabled": true,
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-github"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_TOKEN"
+ }
+ },
+ "postgres": {
+ "enabled": true,
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-postgres",
+ "postgresql://user:password@localhost/dbname"
+ ]
+ },
+ "slack": {
+ "enabled": true,
+ "command": "npx",
+ "args": [
+ "-y",
+ "@modelcontextprotocol/server-slack"
+ ],
+ "env": {
+ "SLACK_BOT_TOKEN": "YOUR_SLACK_BOT_TOKEN",
+ "SLACK_TEAM_ID": "YOUR_SLACK_TEAM_ID"
+ }
+ }
+ }
+ }
+ }
+}
+```
+
## Skills Tool
The skills tool configures skill discovery and installation via registries like ClawHub.
### Registries
-| Config | Type | Default | Description |
-| ---------------------------------- | ------ | -------------------- | ----------------------- |
-| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
-| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
+| Config | Type | Default | Description |
+|------------------------------------|--------|----------------------|----------------------------------------------|
+| `registries.clawhub.enabled` | bool | true | Enable ClawHub registry |
+| `registries.clawhub.base_url` | string | `https://clawhub.ai` | ClawHub base URL |
| `registries.clawhub.auth_token` | string | `""` | Optional Bearer token for higher rate limits |
-| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
-| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
-| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
+| `registries.clawhub.search_path` | string | `/api/v1/search` | Search API path |
+| `registries.clawhub.skills_path` | string | `/api/v1/skills` | Skills API path |
+| `registries.clawhub.download_path` | string | `/api/v1/download` | Download API path |
### Configuration Example
@@ -217,4 +313,5 @@ For example:
- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10`
- `PICOCLAW_TOOLS_MCP_ENABLED=true`
-Note: Nested map-style config (for example `tools.mcp.servers.
.*`) is configured in `config.json` rather than environment variables.
+Note: Nested map-style config (for example `tools.mcp.servers..*`) is configured in `config.json` rather than
+environment variables.
diff --git a/pkg/channels/base.go b/pkg/channels/base.go
index 5927408ee..edb5b6f08 100644
--- a/pkg/channels/base.go
+++ b/pkg/channels/base.go
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"encoding/binary"
"encoding/hex"
+ "regexp"
"strconv"
"strings"
"sync/atomic"
@@ -32,6 +33,9 @@ func init() {
uniqueIDPrefix = hex.EncodeToString(b[:])
}
+// audioAnnotationRe matches audio/voice annotations injected by channels (e.g. [voice], [audio: file.ogg]).
+var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
+
// uniqueID generates a process-unique ID using a random prefix and an atomic counter.
// This ID is intended for internal correlation (e.g. media scope keys) and is NOT
// cryptographically secure — it must not be used in contexts where unpredictability matters.
@@ -285,10 +289,10 @@ func (c *BaseChannel) HandleMessage(
}
}
// Placeholder — independent pipeline.
- // Skip for DraftSender channels: the streaming draft bubble serves
- // as the placeholder, and sendMessage on final response replaces it.
- // Sending both a placeholder AND drafts causes duplicate chat bubbles.
- if _, isDrafter := c.owner.(DraftSender); !isDrafter {
+ // Skip when the message contains audio: the agent will send the
+ // placeholder after transcription completes, so the user sees
+ // "Thinking…" only once the voice has been processed.
+ if !audioAnnotationRe.MatchString(content) {
if pc, ok := c.owner.(PlaceholderCapable); ok {
if phID, err := pc.SendPlaceholder(ctx, chatID); err == nil && phID != "" {
c.placeholderRecorder.RecordPlaceholder(c.name, chatID, phID)
diff --git a/pkg/channels/dingtalk/dingtalk.go b/pkg/channels/dingtalk/dingtalk.go
index 8642ad362..c03122892 100644
--- a/pkg/channels/dingtalk/dingtalk.go
+++ b/pkg/channels/dingtalk/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"
+ dinglog "github.com/open-dingtalk/dingtalk-stream-sdk-go/logger"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
@@ -39,6 +40,9 @@ func NewDingTalkChannel(cfg config.DingTalkConfig, messageBus *bus.MessageBus) (
return nil, fmt.Errorf("dingtalk client_id and client_secret are required")
}
+ // Set the logger for the Stream SDK
+ dinglog.SetLogger(logger.NewLogger("dingtalk"))
+
base := channels.NewBaseChannel("dingtalk", cfg, messageBus, cfg.AllowFrom,
channels.WithMaxMessageLength(20000),
channels.WithGroupTrigger(cfg.GroupTrigger),
diff --git a/pkg/channels/line/line.go b/pkg/channels/line/line.go
index b36350a06..56ba02183 100644
--- a/pkg/channels/line/line.go
+++ b/pkg/channels/line/line.go
@@ -32,6 +32,10 @@ const (
lineBotInfoEndpoint = lineAPIBase + "/info"
lineLoadingEndpoint = lineAPIBase + "/chat/loading/start"
lineReplyTokenMaxAge = 25 * time.Second
+
+ // Limit request body to prevent memory exhaustion (DoS).
+ // LINE webhook payloads are typically a few KB; 1 MiB is generous.
+ maxWebhookBodySize = 1 << 20 // 1 MiB
)
type replyTokenEntry struct {
@@ -166,7 +170,7 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
return
}
- body, err := io.ReadAll(r.Body)
+ body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookBodySize+1))
if err != nil {
logger.ErrorCF("line", "Failed to read request body", map[string]any{
"error": err.Error(),
@@ -174,6 +178,11 @@ func (c *LINEChannel) webhookHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
+ if int64(len(body)) > maxWebhookBodySize {
+ logger.WarnC("line", "Webhook request body too large, rejected")
+ http.Error(w, "Request entity too large", http.StatusRequestEntityTooLarge)
+ return
+ }
signature := r.Header.Get("X-Line-Signature")
if !c.verifySignature(body, signature) {
diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go
index a36dd3eba..aed6a1874 100644
--- a/pkg/commands/builtin.go
+++ b/pkg/commands/builtin.go
@@ -12,5 +12,6 @@ func BuiltinDefinitions() []Definition {
listCommand(),
switchCommand(),
checkCommand(),
+ clearCommand(),
}
}
diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go
index 227d495f4..037184686 100644
--- a/pkg/commands/runtime.go
+++ b/pkg/commands/runtime.go
@@ -13,4 +13,5 @@ type Runtime struct {
GetEnabledChannels func() []string
SwitchModel func(value string) (oldModel string, err error)
SwitchChannel func(value string) error
+ ClearHistory func() error
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 45cf8976e..0d1bacdbc 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -227,18 +227,18 @@ type AgentDefaults struct {
ModelName string `json:"model_name,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` // Deprecated: use model_name instead
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
- ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
- ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
PlanModel string `json:"plan_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_PLAN_MODEL"`
PlanModelFallbacks []string `json:"plan_model_fallbacks,omitempty"`
+ ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
+ ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
- TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
- Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
+ TaskReminderInterval int `json:"task_reminder_interval" env:"PICOCLAW_AGENTS_DEFAULTS_TASK_REMINDER_INTERVAL"`
+ Orchestration bool `json:"orchestration,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_ORCHESTRATION"`
Routing *RoutingConfig `json:"routing,omitempty"`
}
@@ -304,19 +304,20 @@ type WhatsAppConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"`
}
+
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"`
Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
- WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Typing TypingConfig `json:"typing,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
+ WebAppURL string `json:"web_app_url" env:"PICOCLAW_CHANNELS_TELEGRAM_WEB_APP_URL"`
SubagentThreadID int `json:"subagent_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_SUBAGENT_THREAD_ID"`
HeartbeatThreadID int `json:"heartbeat_thread_id,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_HEARTBEAT_THREAD_ID"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_TELEGRAM_REASONING_CHANNEL_ID"`
}
type FeishuConfig struct {
@@ -394,7 +395,7 @@ type MatrixConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MATRIX_ALLOW_FROM"`
GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"`
Placeholder PlaceholderConfig `json:"placeholder,omitempty"`
- ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
+ ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_MATRIX_REASONING_CHANNEL_ID"`
}
type LINEConfig struct {
@@ -609,12 +610,13 @@ type ModelConfig struct {
AuthMethod string `json:"auth_method,omitempty"` // Authentication method: oauth, token
ConnectMode string `json:"connect_mode,omitempty"` // Connection mode: stdio, grpc
Workspace string `json:"workspace,omitempty"` // Workspace path for CLI-based providers
+
// Optional optimizations
RPM int `json:"rpm,omitempty"` // Requests per minute limit
MaxTokensField string `json:"max_tokens_field,omitempty"` // Field name for max tokens (e.g., "max_completion_tokens")
- Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent)
RequestTimeout int `json:"request_timeout,omitempty"`
ThinkingLevel string `json:"thinking_level,omitempty"` // Extended thinking: off|low|medium|high|xhigh|adaptive
+ Stream *bool `json:"stream,omitempty"` // Use SSE streaming (default: protocol-dependent)
}
// Validate checks if the ModelConfig has all required fields.
diff --git a/pkg/config/migration.go b/pkg/config/migration.go
index bb04fd2a8..af6391651 100644
--- a/pkg/config/migration.go
+++ b/pkg/config/migration.go
@@ -407,6 +407,23 @@ func ConvertProvidersToModelList(cfg *Config) []ModelConfig {
}, true
},
},
+ {
+ providerNames: []string{"longcat"},
+ protocol: "longcat",
+ buildConfig: func(p ProvidersConfig) (ModelConfig, bool) {
+ if p.LongCat.APIKey == "" && p.LongCat.APIBase == "" {
+ return ModelConfig{}, false
+ }
+ return ModelConfig{
+ ModelName: "longcat",
+ Model: "longcat/LongCat-Flash-Thinking",
+ APIKey: p.LongCat.APIKey,
+ APIBase: p.LongCat.APIBase,
+ Proxy: p.LongCat.Proxy,
+ RequestTimeout: p.LongCat.RequestTimeout,
+ }, true
+ },
+ },
}
// Process each provider migration
diff --git a/pkg/health/server.go b/pkg/health/server.go
index 0a530a3e8..5609ebdf6 100644
--- a/pkg/health/server.go
+++ b/pkg/health/server.go
@@ -2,7 +2,6 @@ package health
import (
"context"
- "crypto/tls"
"encoding/json"
"fmt"
"maps"
@@ -13,7 +12,6 @@ import (
type Server struct {
server *http.Server
- mux *http.ServeMux
mu sync.RWMutex
ready bool
checks map[string]Check
@@ -36,7 +34,6 @@ type StatusResponse struct {
func NewServer(host string, port int) *Server {
mux := http.NewServeMux()
s := &Server{
- mux: mux,
ready: false,
checks: make(map[string]Check),
startTime: time.Now(),
@@ -49,34 +46,13 @@ func NewServer(host string, port int) *Server {
s.server = &http.Server{
Addr: addr,
Handler: mux,
- ReadTimeout: 30 * time.Second,
- WriteTimeout: 30 * time.Second,
+ ReadTimeout: 5 * time.Second,
+ WriteTimeout: 5 * time.Second,
}
return s
}
-// Mux returns the underlying ServeMux so additional routes can be registered.
-func (s *Server) Mux() *http.ServeMux {
- return s.mux
-}
-
-// StartTLS starts the server with TLS using the provided certificate and key files.
-func (s *Server) StartTLS(certFile, keyFile string) error {
- s.mu.Lock()
- s.ready = true
- s.mu.Unlock()
-
- cert, err := tls.LoadX509KeyPair(certFile, keyFile)
- if err != nil {
- return fmt.Errorf("failed to load TLS cert: %w", err)
- }
- s.server.TLSConfig = &tls.Config{
- Certificates: []tls.Certificate{cert},
- }
- return s.server.ListenAndServeTLS("", "")
-}
-
func (s *Server) Start() error {
s.mu.Lock()
s.ready = true
diff --git a/pkg/health/server_ext.go b/pkg/health/server_ext.go
new file mode 100644
index 000000000..8fff4a74c
--- /dev/null
+++ b/pkg/health/server_ext.go
@@ -0,0 +1,28 @@
+package health
+
+import (
+ "crypto/tls"
+ "fmt"
+ "net/http"
+)
+
+// Mux returns the underlying ServeMux so additional routes can be registered.
+func (s *Server) Mux() *http.ServeMux {
+ return s.server.Handler.(*http.ServeMux)
+}
+
+// StartTLS starts the server with TLS using the provided certificate and key files.
+func (s *Server) StartTLS(certFile, keyFile string) error {
+ s.mu.Lock()
+ s.ready = true
+ s.mu.Unlock()
+
+ cert, err := tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ return fmt.Errorf("failed to load TLS cert: %w", err)
+ }
+ s.server.TLSConfig = &tls.Config{
+ Certificates: []tls.Certificate{cert},
+ }
+ return s.server.ListenAndServeTLS("", "")
+}
diff --git a/pkg/memory/jsonl.go b/pkg/memory/jsonl.go
index e12e2c5ab..afe374166 100644
--- a/pkg/memory/jsonl.go
+++ b/pkg/memory/jsonl.go
@@ -86,14 +86,14 @@ func (s *JSONLStore) metaPath(key string) string {
// sanitizeKey converts a session key to a safe filename component.
// Mirrors pkg/session.sanitizeFilename so that migration paths match.
-//
-// Note: this is a lossy mapping — "telegram:123" and "telegram_123"
-// both produce the same filename. This is an intentional tradeoff:
-// keys with colons (e.g. from channels) are by far the common case,
-// and a bidirectional encoding (like URL-encoding) would complicate
-// file listings and debugging.
+// Replaces ':' with '_' (session key separator) and '/' and '\' with '_'
+// so composite IDs (e.g. Telegram forum "chatID/threadID", Slack "channel/thread_ts")
+// do not create subdirectories or break on Windows.
func sanitizeKey(key string) string {
- return strings.ReplaceAll(key, ":", "_")
+ s := strings.ReplaceAll(key, ":", "_")
+ s = strings.ReplaceAll(s, "/", "_")
+ s = strings.ReplaceAll(s, "\\", "_")
+ return s
}
// readMeta loads the metadata file for a session.
diff --git a/pkg/memory/migration.go b/pkg/memory/migration.go
index c9d5176ab..b64c62a9f 100644
--- a/pkg/memory/migration.go
+++ b/pkg/memory/migration.go
@@ -48,6 +48,12 @@ func MigrateFromJSON(
if !strings.HasSuffix(name, ".json") {
continue
}
+ // Skip JSONL metadata files. They are part of the new storage format,
+ // not legacy session snapshots, and re-importing them would overwrite
+ // the paired .jsonl history with an empty message list.
+ if strings.HasSuffix(name, ".meta.json") {
+ continue
+ }
// Skip already-migrated files.
if strings.HasSuffix(name, ".migrated") {
continue
diff --git a/pkg/migrate/sources/openclaw/common.go b/pkg/migrate/sources/openclaw/common.go
index d57dbe34f..7cbd2d1e6 100644
--- a/pkg/migrate/sources/openclaw/common.go
+++ b/pkg/migrate/sources/openclaw/common.go
@@ -4,8 +4,8 @@ var migrateableFiles = []string{
"AGENTS.md",
"SOUL.md",
"USER.md",
- "TOOLS.md",
"HEARTBEAT.md",
+ "TOOLS.md",
}
var migrateableDirs = []string{
diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go
index 7a17e92bf..242ded175 100644
--- a/pkg/providers/anthropic/provider.go
+++ b/pkg/providers/anthropic/provider.go
@@ -181,8 +181,10 @@ func buildParams(
}
for _, tc := range msg.ToolCalls {
args := tc.Arguments
- if args == nil && tc.Function != nil && len(tc.Function.Arguments) > 0 {
- args = tc.Function.Arguments
+ if args == nil && tc.Function != nil && tc.Function.Arguments != "" {
+ if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
+ args = map[string]any{}
+ }
}
if args == nil {
args = map[string]any{}
@@ -306,19 +308,16 @@ func levelToBudget(level string) int {
func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
result := make([]anthropic.ToolUnionParam, 0, len(tools))
for _, t := range tools {
- params := t.Function.ParametersMap()
tool := anthropic.ToolParam{
Name: t.Function.Name,
InputSchema: anthropic.ToolInputSchemaParam{
- Properties: params["properties"],
+ Properties: t.Function.Parameters["properties"],
},
}
if desc := t.Function.Description; desc != "" {
tool.Description = anthropic.String(desc)
}
-
- switch req := params["required"].(type) {
- case []any:
+ if req, ok := t.Function.Parameters["required"].([]any); ok {
required := make([]string, 0, len(req))
for _, r := range req {
if s, ok := r.(string); ok {
@@ -326,8 +325,6 @@ func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam {
}
}
tool.InputSchema.Required = required
- case []string:
- tool.InputSchema.Required = append([]string(nil), req...)
}
result = append(result, anthropic.ToolUnionParam{OfTool: &tool})
}
diff --git a/pkg/routing/session_key.go b/pkg/routing/session_key.go
index 19e6dd390..fa08fdde3 100644
--- a/pkg/routing/session_key.go
+++ b/pkg/routing/session_key.go
@@ -42,11 +42,6 @@ func BuildAgentMainSessionKey(agentID string) string {
return fmt.Sprintf("agent:%s:%s", NormalizeAgentID(agentID), DefaultMainKey)
}
-// BuildSubagentSessionKey returns "subagent:" for subagent sessions.
-func BuildSubagentSessionKey(taskID string) string {
- return fmt.Sprintf("subagent:%s", taskID)
-}
-
// BuildAgentPeerSessionKey constructs a session key based on agent, channel, peer, and DM scope.
func BuildAgentPeerSessionKey(params SessionKeyParams) string {
agentID := NormalizeAgentID(params.AgentID)
@@ -195,3 +190,8 @@ func resolveLinkedPeerID(identityLinks map[string][]string, channel, peerID stri
}
return ""
}
+
+// BuildSubagentSessionKey returns "subagent:" for subagent sessions.
+func BuildSubagentSessionKey(taskID string) string {
+ return fmt.Sprintf("subagent:%s", taskID)
+}
diff --git a/pkg/skills/clawhub_registry.go b/pkg/skills/clawhub_registry.go
index 76e717e4b..bd4bed8fb 100644
--- a/pkg/skills/clawhub_registry.go
+++ b/pkg/skills/clawhub_registry.go
@@ -8,7 +8,6 @@ import (
"net/http"
"net/url"
"os"
- "strconv"
"time"
"github.com/sipeed/picoclaw/pkg/utils"
@@ -112,7 +111,7 @@ func (c *ClawHubRegistry) Search(ctx context.Context, query string, limit int) (
q := u.Query()
q.Set("q", query)
if limit > 0 {
- q.Set("limit", strconv.Itoa(limit))
+ q.Set("limit", fmt.Sprintf("%d", limit))
}
u.RawQuery = q.Encode()
diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go
index 5cc9ac0a9..45ae72253 100644
--- a/pkg/skills/registry.go
+++ b/pkg/skills/registry.go
@@ -180,7 +180,7 @@ func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit in
close(resultsCh)
}()
- merged := make([]SearchResult, 0, len(regs)*limit)
+ var merged []SearchResult
var lastErr error
var anyRegistrySucceeded bool
diff --git a/pkg/skills/search_cache.go b/pkg/skills/search_cache.go
index 76a9147d2..1686e3f98 100644
--- a/pkg/skills/search_cache.go
+++ b/pkg/skills/search_cache.go
@@ -39,8 +39,8 @@ func NewSearchCache(maxEntries int, ttl time.Duration) *SearchCache {
ttl = 5 * time.Minute
}
return &SearchCache{
- entries: make(map[string]*cacheEntry, maxEntries),
- order: make([]string, 0, maxEntries),
+ entries: make(map[string]*cacheEntry),
+ order: make([]string, 0),
maxEntries: maxEntries,
ttl: ttl,
}
diff --git a/pkg/utils/media.go b/pkg/utils/media.go
index 31d888f79..3e1c5d88e 100644
--- a/pkg/utils/media.go
+++ b/pkg/utils/media.go
@@ -14,13 +14,11 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
-var (
- audioExtensions = []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
- audioTypes = []string{"audio/", "application/ogg", "application/x-ogg"}
-)
-
// IsAudioFile checks if a file is an audio file based on its filename extension and content type.
func IsAudioFile(filename, contentType string) bool {
+ audioExtensions := []string{".mp3", ".wav", ".ogg", ".m4a", ".flac", ".aac", ".wma"}
+ audioTypes := []string{"audio/", "application/ogg", "application/x-ogg"}
+
for _, ext := range audioExtensions {
if strings.HasSuffix(strings.ToLower(filename), ext) {
return true
diff --git a/workspace/IDENTITY.md b/workspace/IDENTITY.md
index dabb0e14b..20e3e49fa 100644
--- a/workspace/IDENTITY.md
+++ b/workspace/IDENTITY.md
@@ -6,9 +6,6 @@ PicoClaw 🦞
## Description
Ultra-lightweight personal AI assistant written in Go, inspired by nanobot.
-## Version
-0.1.0
-
## Purpose
- Provide intelligent AI assistance with minimal resource usage
- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.)