From 15e3c7d08a1ff877e0d21ecc304e01e6fd96f138 Mon Sep 17 00:00:00 2001
From: seth <117851610+Sethispr@users.noreply.github.com>
Date: Fri, 13 Feb 2026 08:05:14 -0800
Subject: [PATCH 01/46] chore: lint readme
MD004 / ul-style Unordered list style [Expected: asterisk; Actual: dash]
MD009 / no-trailing-spaces Trailing spaces [Expected: 0 or 2; Actual: 1]
MD012 / no-multiple-blanks Multiple consecutive blank lines [Expected: 1; Actual: 2]
MD022 / blanks-around-headings Headings should be surrounded by blank lines [Expected: 1; Actual: 0; Below]
MD031 / blanks-around-fences Fenced code blocks should be surrounded by blank lines
MD032 / blanks-around-lists Lists should be surrounded by blank lines
---
README.md | 84 +++++++++++++++++++++++++++----------------------------
1 file changed, 41 insertions(+), 43 deletions(-)
diff --git a/README.md b/README.md
index 2ba70881b..81d155f89 100644
--- a/README.md
+++ b/README.md
@@ -17,14 +17,12 @@
[中文](README.zh.md) | [日本語](README.ja.md) | **English**
-
---
🦐 PicoClaw is an ultra-lightweight personal AI Assistant inspired by [nanobot](https://github.com/HKUDS/nanobot), refactored from the ground up in Go through a self-bootstrapping process, where the AI agent itself drove the entire architectural migration and code optimization.
⚡️ Runs on $10 hardware with <10MB RAM: That's 99% less memory than OpenClaw and 98% cheaper than a Mac mini!
-
-
> [!CAUTION]
> **🚨 SECURITY & OFFICIAL CHANNELS / 安全声明**
>
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
-> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
+> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
>
## 📢 News
+
2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development.
🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting.
-
2026-02-09 🎉 PicoClaw Launched! Built in 1 day to bring AI Agents to $10 hardware with <10MB RAM. 🦐 PicoClaw,Let's Go!
## ✨ Features
@@ -103,9 +100,9 @@
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
+* $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
@@ -220,8 +217,8 @@ picoclaw onboard
**3. Get API Keys**
-- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
-- **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
+* **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys)
+* **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month)
> **Note**: See `config.example.json` for a complete configuration template.
@@ -251,9 +248,9 @@ Talk to your picoclaw through Telegram, Discord, or DingTalk
**1. Create a bot**
-- Open Telegram, search `@BotFather`
-- Send `/newbot`, follow prompts
-- Copy the token
+* Open Telegram, search `@BotFather`
+* Send `/newbot`, follow prompts
+* Copy the token
**2. Configure**
@@ -284,19 +281,19 @@ picoclaw gateway
**1. Create a bot**
-- Go to
-- Create an application → Bot → Add Bot
-- Copy the bot token
+* Go to
+* Create an application → Bot → Add Bot
+* Copy the bot token
**2. Enable intents**
-- In the Bot settings, enable **MESSAGE CONTENT INTENT**
-- (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
+* In the Bot settings, enable **MESSAGE CONTENT INTENT**
+* (Optional) Enable **SERVER MEMBERS INTENT** if you plan to use allow lists based on member data
**3. Get your User ID**
-- Discord Settings → Advanced → enable **Developer Mode**
-- Right-click your avatar → **Copy User ID**
+* Discord Settings → Advanced → enable **Developer Mode**
+* Right-click your avatar → **Copy User ID**
**4. Configure**
@@ -314,10 +311,10 @@ picoclaw gateway
**5. Invite the bot**
-- OAuth2 → URL Generator
-- Scopes: `bot`
-- Bot Permissions: `Send Messages`, `Read Message History`
-- Open the generated invite URL and add the bot to your server
+* OAuth2 → URL Generator
+* Scopes: `bot`
+* Bot Permissions: `Send Messages`, `Read Message History`
+* Open the generated invite URL and add the bot to your server
**6. Run**
@@ -332,8 +329,8 @@ picoclaw gateway
**1. Create a bot**
-- Go to [QQ Open Platform](https://connect.qq.com/)
-- Create an application → Get **AppID** and **AppSecret**
+* Go to [QQ Open Platform](https://connect.qq.com/)
+* Create an application → Get **AppID** and **AppSecret**
**2. Configure**
@@ -365,9 +362,9 @@ picoclaw gateway
**1. Create a bot**
-- Go to [Open Platform](https://open.dingtalk.com/)
-- Create an internal app
-- Copy Client ID and Client Secret
+* Go to [Open Platform](https://open.dingtalk.com/)
+* Create an internal app
+* Copy Client ID and Client Secret
**2. Configure**
@@ -400,8 +397,6 @@ Connect Picoclaw to the Agent Social Network simply by sending a single message
**Read `https://clawdchat.ai/skill.md` and follow the instructions to join [ClawdChat.ai](https://clawdchat.ai)**
-
-
## ⚙️ Configuration
Config file: `~/.picoclaw/config.json`
@@ -464,12 +459,12 @@ When `restrict_to_workspace: true`, the following tools are sandboxed:
Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous commands:
-- `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion
-- `format`, `mkfs`, `diskpart` — Disk formatting
-- `dd if=` — Disk imaging
-- Writing to `/dev/sd[a-z]` — Direct disk writes
-- `shutdown`, `reboot`, `poweroff` — System shutdown
-- Fork bomb `:(){ :|:& };:`
+* `rm -rf`, `del /f`, `rmdir /s` — Bulk deletion
+* `format`, `mkfs`, `diskpart` — Disk formatting
+* `dd if=` — Disk imaging
+* Writing to `/dev/sd[a-z]` — Direct disk writes
+* `shutdown`, `reboot`, `poweroff` — System shutdown
+* Fork bomb `:(){ :|:& };:`
#### Error Examples
@@ -488,6 +483,7 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous
If you need the agent to access paths outside the workspace:
**Method 1: Config file**
+
```json
{
"agents": {
@@ -499,6 +495,7 @@ If you need the agent to access paths outside the workspace:
```
**Method 2: Environment variable**
+
```bash
export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false
```
@@ -590,8 +587,9 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
| `interval` | `30` | Check interval in minutes (min: 5) |
**Environment variables:**
-- `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
-- `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
+
+* `PICOCLAW_HEARTBEAT_ENABLED=false` to disable
+* `PICOCLAW_HEARTBEAT_INTERVAL=60` to change interval
### Providers
@@ -613,7 +611,7 @@ The subagent has access to tools (message, web_search, etc.) and can communicate
**1. Get API key and base URL**
-- Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
+* Get [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys)
**2. Configure**
@@ -724,9 +722,9 @@ picoclaw agent -m "Hello"
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:
-- **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
-- **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
-- **Cron expressions**: "Remind me at 9am daily" → uses cron expression
+* **One-time reminders**: "Remind me in 10 minutes" → triggers once after 10min
+* **Recurring tasks**: "Remind me every 2 hours" → triggers every 2 hours
+* **Cron expressions**: "Remind me at 9am daily" → uses cron expression
Jobs are stored in `~/.picoclaw/workspace/cron/` and processed automatically.
From 7304ab7d3357d861894078ba012231eccc952624 Mon Sep 17 00:00:00 2001
From: qiaoborui
Date: Sat, 14 Feb 2026 12:37:49 +0800
Subject: [PATCH 02/46] fix(auth): align OpenAI OAuth authorize URL and params
---
pkg/auth/oauth.go | 39 +++++++++++++++++++++++----------------
pkg/auth/oauth_test.go | 20 +++++++++++++++-----
2 files changed, 38 insertions(+), 21 deletions(-)
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index ecd9ba265..4f26e0eba 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -19,18 +19,20 @@ import (
)
type OAuthProviderConfig struct {
- Issuer string
- ClientID string
- Scopes string
- Port int
+ Issuer string
+ ClientID string
+ Scopes string
+ Originator string
+ Port int
}
func OpenAIOAuthConfig() OAuthProviderConfig {
return OAuthProviderConfig{
- Issuer: "https://auth.openai.com",
- ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
- Scopes: "openid profile email offline_access",
- Port: 1455,
+ Issuer: "https://auth.openai.com",
+ ClientID: "app_EMoamEEZ73f0CkXaXp7hrann",
+ Scopes: "openid profile email offline_access",
+ Originator: "codex_cli_rs",
+ Port: 1455,
}
}
@@ -288,15 +290,20 @@ func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectU
func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string {
params := url.Values{
- "response_type": {"code"},
- "client_id": {cfg.ClientID},
- "redirect_uri": {redirectURI},
- "scope": {cfg.Scopes},
- "code_challenge": {pkce.CodeChallenge},
- "code_challenge_method": {"S256"},
- "state": {state},
+ "response_type": {"code"},
+ "client_id": {cfg.ClientID},
+ "redirect_uri": {redirectURI},
+ "scope": {cfg.Scopes},
+ "code_challenge": {pkce.CodeChallenge},
+ "code_challenge_method": {"S256"},
+ "id_token_add_organizations": {"true"},
+ "codex_cli_simplified_flow": {"true"},
+ "state": {state},
}
- return cfg.Issuer + "/authorize?" + params.Encode()
+ if cfg.Originator != "" {
+ params.Set("originator", cfg.Originator)
+ }
+ return cfg.Issuer + "/oauth/authorize?" + params.Encode()
}
func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) {
diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go
index 9f8013217..2348ee277 100644
--- a/pkg/auth/oauth_test.go
+++ b/pkg/auth/oauth_test.go
@@ -10,10 +10,11 @@ import (
func TestBuildAuthorizeURL(t *testing.T) {
cfg := OAuthProviderConfig{
- Issuer: "https://auth.example.com",
- ClientID: "test-client-id",
- Scopes: "openid profile",
- Port: 1455,
+ Issuer: "https://auth.example.com",
+ ClientID: "test-client-id",
+ Scopes: "openid profile",
+ Originator: "codex_cli_rs",
+ Port: 1455,
}
pkce := PKCECodes{
CodeVerifier: "test-verifier",
@@ -22,7 +23,7 @@ func TestBuildAuthorizeURL(t *testing.T) {
u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback")
- if !strings.HasPrefix(u, "https://auth.example.com/authorize?") {
+ if !strings.HasPrefix(u, "https://auth.example.com/oauth/authorize?") {
t.Errorf("URL does not start with expected prefix: %s", u)
}
if !strings.Contains(u, "client_id=test-client-id") {
@@ -40,6 +41,15 @@ func TestBuildAuthorizeURL(t *testing.T) {
if !strings.Contains(u, "response_type=code") {
t.Error("URL missing response_type")
}
+ if !strings.Contains(u, "id_token_add_organizations=true") {
+ t.Error("URL missing id_token_add_organizations")
+ }
+ if !strings.Contains(u, "codex_cli_simplified_flow=true") {
+ t.Error("URL missing codex_cli_simplified_flow")
+ }
+ if !strings.Contains(u, "originator=codex_cli_rs") {
+ t.Error("URL missing originator")
+ }
}
func TestParseTokenResponse(t *testing.T) {
From da804a074858b07633c585f91c4a765601f142d7 Mon Sep 17 00:00:00 2001
From: qiaoborui
Date: Sat, 14 Feb 2026 12:48:16 +0800
Subject: [PATCH 03/46] fix(codex): include required instructions and improve
account-id extraction
---
pkg/auth/oauth.go | 3 +++
pkg/auth/oauth_test.go | 27 +++++++++++++++++++++++++++
pkg/providers/codex_provider.go | 5 +++++
pkg/providers/codex_provider_test.go | 6 ++++++
4 files changed, 41 insertions(+)
diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go
index 4f26e0eba..1a6589641 100644
--- a/pkg/auth/oauth.go
+++ b/pkg/auth/oauth.go
@@ -359,6 +359,9 @@ func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) {
if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" {
cred.AccountID = accountID
+ } else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" {
+ // Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims.
+ cred.AccountID = accountID
}
return cred, nil
diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go
index 2348ee277..0d2ccc9a5 100644
--- a/pkg/auth/oauth_test.go
+++ b/pkg/auth/oauth_test.go
@@ -1,6 +1,7 @@
package auth
import (
+ "encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -91,6 +92,32 @@ func TestParseTokenResponseNoAccessToken(t *testing.T) {
}
}
+func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) {
+ idToken := makeJWTWithAccountID("acc-from-id")
+ resp := map[string]interface{}{
+ "access_token": "not-a-jwt",
+ "refresh_token": "test-refresh-token",
+ "expires_in": 3600,
+ "id_token": idToken,
+ }
+ body, _ := json.Marshal(resp)
+
+ cred, err := parseTokenResponse(body, "openai")
+ if err != nil {
+ t.Fatalf("parseTokenResponse() error: %v", err)
+ }
+
+ if cred.AccountID != "acc-from-id" {
+ t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-from-id")
+ }
+}
+
+func makeJWTWithAccountID(accountID string) string {
+ header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
+ payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`))
+ return header + "." + payload + ".sig"
+}
+
func TestExchangeCodeForTokens(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/oauth/token" {
diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go
index 3463389a5..c0b10bd5b 100644
--- a/pkg/providers/codex_provider.go
+++ b/pkg/providers/codex_provider.go
@@ -18,6 +18,8 @@ type CodexProvider struct {
tokenSource func() (string, string, error)
}
+const defaultCodexInstructions = "You are Codex, a coding assistant."
+
func NewCodexProvider(token, accountID string) *CodexProvider {
opts := []option.RequestOption{
option.WithBaseURL("https://chatgpt.com/backend-api/codex"),
@@ -138,6 +140,9 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string,
if instructions != "" {
params.Instructions = openai.Opt(instructions)
+ } else {
+ // ChatGPT Codex backend requires instructions to be present.
+ params.Instructions = openai.Opt(defaultCodexInstructions)
}
if maxTokens, ok := options["max_tokens"].(int); ok {
diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go
index 605183d5e..1a5a8cafa 100644
--- a/pkg/providers/codex_provider_test.go
+++ b/pkg/providers/codex_provider_test.go
@@ -21,6 +21,12 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) {
if params.Model != "gpt-4o" {
t.Errorf("Model = %q, want %q", params.Model, "gpt-4o")
}
+ if !params.Instructions.Valid() {
+ t.Fatal("Instructions should be set")
+ }
+ if params.Instructions.Or("") != defaultCodexInstructions {
+ t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions)
+ }
}
func TestBuildCodexParams_SystemAsInstructions(t *testing.T) {
From 7fa641a2edb41bd69268112d8683ded97a65a767 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 14 Feb 2026 16:50:21 +0800
Subject: [PATCH 04/46] feat: add OneBot channel support
---
config/config.example.json | 8 +
pkg/channels/manager.go | 13 +
pkg/channels/onebot.go | 677 +++++++++++++++++++++++++++++++++++++
pkg/config/config.go | 18 +
4 files changed, 716 insertions(+)
create mode 100644 pkg/channels/onebot.go
diff --git a/config/config.example.json b/config/config.example.json
index ee3ac97f7..f7e88357f 100644
--- a/config/config.example.json
+++ b/config/config.example.json
@@ -60,6 +60,14 @@
"webhook_port": 18791,
"webhook_path": "/webhook/line",
"allow_from": []
+ },
+ "onebot": {
+ "enabled": false,
+ "ws_url": "ws://127.0.0.1:3001",
+ "access_token": "",
+ "reconnect_interval": 5,
+ "group_trigger_prefix": [],
+ "allow_from": []
}
},
"providers": {
diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go
index 69e9b2b43..15f8c6037 100644
--- a/pkg/channels/manager.go
+++ b/pkg/channels/manager.go
@@ -163,6 +163,19 @@ func (m *Manager) initChannels() error {
}
}
+ if m.config.Channels.OneBot.Enabled && m.config.Channels.OneBot.WSUrl != "" {
+ logger.DebugC("channels", "Attempting to initialize OneBot channel")
+ onebot, err := NewOneBotChannel(m.config.Channels.OneBot, m.bus)
+ if err != nil {
+ logger.ErrorCF("channels", "Failed to initialize OneBot channel", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ m.channels["onebot"] = onebot
+ logger.InfoC("channels", "OneBot channel enabled successfully")
+ }
+ }
+
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
"enabled_channels": len(m.channels),
})
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
new file mode 100644
index 000000000..fe6edbeea
--- /dev/null
+++ b/pkg/channels/onebot.go
@@ -0,0 +1,677 @@
+package channels
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "github.com/sipeed/picoclaw/pkg/bus"
+ "github.com/sipeed/picoclaw/pkg/config"
+ "github.com/sipeed/picoclaw/pkg/logger"
+)
+
+type OneBotChannel struct {
+ *BaseChannel
+ config config.OneBotConfig
+ conn *websocket.Conn
+ ctx context.Context
+ cancel context.CancelFunc
+ dedup map[string]struct{}
+ dedupRing []string
+ dedupIdx int
+ mu sync.Mutex
+ writeMu sync.Mutex
+ echoCounter int64
+}
+
+
+type oneBotRawEvent struct {
+ PostType string `json:"post_type"`
+ MessageType string `json:"message_type"`
+ SubType string `json:"sub_type"`
+ MessageID json.RawMessage `json:"message_id"`
+ UserID json.RawMessage `json:"user_id"`
+ GroupID json.RawMessage `json:"group_id"`
+ RawMessage string `json:"raw_message"`
+ Message json.RawMessage `json:"message"`
+ Sender json.RawMessage `json:"sender"`
+ SelfID json.RawMessage `json:"self_id"`
+ Time json.RawMessage `json:"time"`
+ MetaEventType string `json:"meta_event_type"`
+ Echo string `json:"echo"`
+ RetCode json.RawMessage `json:"retcode"`
+ Status string `json:"status"`
+}
+
+type oneBotSender struct {
+ UserID json.RawMessage `json:"user_id"`
+ Nickname string `json:"nickname"`
+ Card string `json:"card"`
+}
+
+type oneBotEvent struct {
+ PostType string
+ MessageType string
+ SubType string
+ MessageID string
+ UserID int64
+ GroupID int64
+ Content string
+ RawContent string
+ IsBotMentioned bool
+ Sender oneBotSender
+ SelfID int64
+ Time int64
+ MetaEventType string
+}
+
+type oneBotAPIRequest struct {
+ Action string `json:"action"`
+ Params interface{} `json:"params"`
+ Echo string `json:"echo,omitempty"`
+}
+
+type oneBotSendPrivateMsgParams struct {
+ UserID int64 `json:"user_id"`
+ Message string `json:"message"`
+}
+
+type oneBotSendGroupMsgParams struct {
+ GroupID int64 `json:"group_id"`
+ Message string `json:"message"`
+}
+
+func NewOneBotChannel(cfg config.OneBotConfig, messageBus *bus.MessageBus) (*OneBotChannel, error) {
+ base := NewBaseChannel("onebot", cfg, messageBus, cfg.AllowFrom)
+
+ const dedupSize = 1024
+ return &OneBotChannel{
+ BaseChannel: base,
+ config: cfg,
+ dedup: make(map[string]struct{}, dedupSize),
+ dedupRing: make([]string, dedupSize),
+ dedupIdx: 0,
+ }, nil
+}
+
+func (c *OneBotChannel) Start(ctx context.Context) error {
+ if c.config.WSUrl == "" {
+ return fmt.Errorf("OneBot ws_url not configured")
+ }
+
+ logger.InfoCF("onebot", "Starting OneBot channel", map[string]interface{}{
+ "ws_url": c.config.WSUrl,
+ })
+
+ c.ctx, c.cancel = context.WithCancel(ctx)
+
+ if err := c.connect(); err != nil {
+ return fmt.Errorf("failed to connect to OneBot: %w", err)
+ }
+
+ go c.listen()
+
+ if c.config.ReconnectInterval > 0 {
+ go c.reconnectLoop()
+ }
+
+ c.setRunning(true)
+ logger.InfoC("onebot", "OneBot channel started successfully")
+
+ return nil
+}
+
+func (c *OneBotChannel) connect() error {
+ dialer := websocket.DefaultDialer
+ dialer.HandshakeTimeout = 10 * time.Second
+
+ header := make(map[string][]string)
+ if c.config.AccessToken != "" {
+ header["Authorization"] = []string{"Bearer " + c.config.AccessToken}
+ }
+
+ conn, _, err := dialer.Dial(c.config.WSUrl, header)
+ if err != nil {
+ return err
+ }
+
+ c.mu.Lock()
+ c.conn = conn
+ c.mu.Unlock()
+
+ logger.InfoC("onebot", "WebSocket connected")
+ return nil
+}
+
+func (c *OneBotChannel) reconnectLoop() {
+ interval := time.Duration(c.config.ReconnectInterval) * time.Second
+ if interval < 5*time.Second {
+ interval = 5 * time.Second
+ }
+
+ for {
+ select {
+ case <-c.ctx.Done():
+ return
+ case <-time.After(interval):
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ logger.InfoC("onebot", "Attempting to reconnect...")
+ if err := c.connect(); err != nil {
+ logger.ErrorCF("onebot", "Reconnect failed", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ go c.listen()
+ }
+ }
+ }
+ }
+}
+
+func (c *OneBotChannel) Stop(ctx context.Context) error {
+ logger.InfoC("onebot", "Stopping OneBot channel")
+ c.setRunning(false)
+
+ if c.cancel != nil {
+ c.cancel()
+ }
+
+ c.mu.Lock()
+ if c.conn != nil {
+ c.conn.Close()
+ c.conn = nil
+ }
+ c.mu.Unlock()
+
+ return nil
+}
+
+func (c *OneBotChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
+ if !c.IsRunning() {
+ return fmt.Errorf("OneBot channel not running")
+ }
+
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ return fmt.Errorf("OneBot WebSocket not connected")
+ }
+
+ action, params, err := c.buildSendRequest(msg)
+ if err != nil {
+ return err
+ }
+
+ c.writeMu.Lock()
+ c.echoCounter++
+ echo := fmt.Sprintf("send_%d", c.echoCounter)
+ c.writeMu.Unlock()
+
+ req := oneBotAPIRequest{
+ Action: action,
+ Params: params,
+ Echo: echo,
+ }
+
+ data, err := json.Marshal(req)
+ if err != nil {
+ return fmt.Errorf("failed to marshal OneBot request: %w", err)
+ }
+
+ c.writeMu.Lock()
+ err = conn.WriteMessage(websocket.TextMessage, data)
+ c.writeMu.Unlock()
+
+ if err != nil {
+ logger.ErrorCF("onebot", "Failed to send message", map[string]interface{}{
+ "error": err.Error(),
+ })
+ return err
+ }
+
+ return nil
+}
+
+func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, interface{}, error) {
+ chatID := msg.ChatID
+
+ if len(chatID) > 6 && chatID[:6] == "group:" {
+ groupID, err := strconv.ParseInt(chatID[6:], 10, 64)
+ if err != nil {
+ return "", nil, fmt.Errorf("invalid group ID in chatID: %s", chatID)
+ }
+ return "send_group_msg", oneBotSendGroupMsgParams{
+ GroupID: groupID,
+ Message: msg.Content,
+ }, nil
+ }
+
+ if len(chatID) > 8 && chatID[:8] == "private:" {
+ userID, err := strconv.ParseInt(chatID[8:], 10, 64)
+ if err != nil {
+ return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID)
+ }
+ return "send_private_msg", oneBotSendPrivateMsgParams{
+ UserID: userID,
+ Message: msg.Content,
+ }, nil
+ }
+
+ userID, err := strconv.ParseInt(chatID, 10, 64)
+ if err != nil {
+ return "", nil, fmt.Errorf("invalid chatID for OneBot: %s", chatID)
+ }
+
+ return "send_private_msg", oneBotSendPrivateMsgParams{
+ UserID: userID,
+ Message: msg.Content,
+ }, nil
+}
+
+func (c *OneBotChannel) listen() {
+ logger.InfoC("onebot", "WebSocket listener started")
+ for {
+ select {
+ case <-c.ctx.Done():
+ logger.InfoC("onebot", "WebSocket listener stopping (context cancelled)")
+ return
+ default:
+ c.mu.Lock()
+ conn := c.conn
+ c.mu.Unlock()
+
+ if conn == nil {
+ logger.WarnC("onebot", "WebSocket connection is nil, listener exiting")
+ return
+ }
+
+ _, message, err := conn.ReadMessage()
+ if err != nil {
+ logger.ErrorCF("onebot", "WebSocket read error", map[string]interface{}{
+ "error": err.Error(),
+ })
+ c.mu.Lock()
+ if c.conn != nil {
+ c.conn.Close()
+ c.conn = nil
+ }
+ c.mu.Unlock()
+ return
+ }
+
+ logger.DebugCF("onebot", "Raw WebSocket message received", map[string]interface{}{
+ "length": len(message),
+ "payload": string(message),
+ })
+
+ var raw oneBotRawEvent
+ if err := json.Unmarshal(message, &raw); err != nil {
+ logger.WarnCF("onebot", "Failed to unmarshal raw event", map[string]interface{}{
+ "error": err.Error(),
+ "payload": string(message),
+ })
+ continue
+ }
+
+ if raw.Echo != "" || raw.Status != "" {
+ logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{
+ "echo": raw.Echo,
+ "status": raw.Status,
+ })
+ continue
+ }
+
+ logger.DebugCF("onebot", "Parsed raw event", map[string]interface{}{
+ "post_type": raw.PostType,
+ "message_type": raw.MessageType,
+ "sub_type": raw.SubType,
+ "meta_event_type": raw.MetaEventType,
+ })
+
+ c.handleRawEvent(&raw)
+ }
+ }
+}
+
+func parseJSONInt64(raw json.RawMessage) (int64, error) {
+ if len(raw) == 0 {
+ return 0, nil
+ }
+
+ var n int64
+ if err := json.Unmarshal(raw, &n); err == nil {
+ return n, nil
+ }
+
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ return strconv.ParseInt(s, 10, 64)
+ }
+ return 0, fmt.Errorf("cannot parse as int64: %s", string(raw))
+}
+
+func parseJSONString(raw json.RawMessage) string {
+ if len(raw) == 0 {
+ return ""
+ }
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ return s
+ }
+
+ return string(raw)
+}
+
+type parseMessageResult struct {
+ Text string
+ IsBotMentioned bool
+}
+
+func parseMessageContentEx(raw json.RawMessage, selfID int64) parseMessageResult {
+ if len(raw) == 0 {
+ return parseMessageResult{}
+ }
+
+ var s string
+ if err := json.Unmarshal(raw, &s); err == nil {
+ mentioned := false
+ if selfID > 0 {
+ cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
+ if strings.Contains(s, cqAt) {
+ mentioned = true
+ s = strings.ReplaceAll(s, cqAt, "")
+ s = strings.TrimSpace(s)
+ }
+ }
+ return parseMessageResult{Text: s, IsBotMentioned: mentioned}
+ }
+
+ var segments []map[string]interface{}
+ if err := json.Unmarshal(raw, &segments); err == nil {
+ var text string
+ mentioned := false
+ selfIDStr := strconv.FormatInt(selfID, 10)
+ for _, seg := range segments {
+ segType, _ := seg["type"].(string)
+ data, _ := seg["data"].(map[string]interface{})
+ switch segType {
+ case "text":
+ if data != nil {
+ if t, ok := data["text"].(string); ok {
+ text += t
+ }
+ }
+ case "at":
+ if data != nil && selfID > 0 {
+ qqVal := fmt.Sprintf("%v", data["qq"])
+ if qqVal == selfIDStr || qqVal == "all" {
+ mentioned = true
+ }
+ }
+ }
+ }
+ return parseMessageResult{Text: strings.TrimSpace(text), IsBotMentioned: mentioned}
+ }
+ return parseMessageResult{}
+}
+
+func (c *OneBotChannel) handleRawEvent(raw *oneBotRawEvent) {
+ switch raw.PostType {
+ case "message":
+ evt, err := c.normalizeMessageEvent(raw)
+ if err != nil {
+ logger.WarnCF("onebot", "Failed to normalize message event", map[string]interface{}{
+ "error": err.Error(),
+ })
+ return
+ }
+ c.handleMessage(evt)
+ case "meta_event":
+ c.handleMetaEvent(raw)
+ case "notice":
+ logger.DebugCF("onebot", "Notice event received", map[string]interface{}{
+ "sub_type": raw.SubType,
+ })
+ case "request":
+ logger.DebugCF("onebot", "Request event received", map[string]interface{}{
+ "sub_type": raw.SubType,
+ })
+ case "":
+ logger.DebugCF("onebot", "Event with empty post_type (possibly API response)", map[string]interface{}{
+ "echo": raw.Echo,
+ "status": raw.Status,
+ })
+ default:
+ logger.DebugCF("onebot", "Unknown post_type", map[string]interface{}{
+ "post_type": raw.PostType,
+ })
+ }
+}
+
+func (c *OneBotChannel) normalizeMessageEvent(raw *oneBotRawEvent) (*oneBotEvent, error) {
+ userID, err := parseJSONInt64(raw.UserID)
+ if err != nil {
+ return nil, fmt.Errorf("parse user_id: %w (raw: %s)", err, string(raw.UserID))
+ }
+
+ groupID, _ := parseJSONInt64(raw.GroupID)
+ selfID, _ := parseJSONInt64(raw.SelfID)
+ ts, _ := parseJSONInt64(raw.Time)
+ messageID := parseJSONString(raw.MessageID)
+
+ parsed := parseMessageContentEx(raw.Message, selfID)
+ isBotMentioned := parsed.IsBotMentioned
+
+ content := raw.RawMessage
+ if content == "" {
+ content = parsed.Text
+ } else if selfID > 0 {
+ cqAt := fmt.Sprintf("[CQ:at,qq=%d]", selfID)
+ if strings.Contains(content, cqAt) {
+ isBotMentioned = true
+ content = strings.ReplaceAll(content, cqAt, "")
+ content = strings.TrimSpace(content)
+ }
+ }
+
+ var sender oneBotSender
+ if len(raw.Sender) > 0 {
+ if err := json.Unmarshal(raw.Sender, &sender); err != nil {
+ logger.WarnCF("onebot", "Failed to parse sender", map[string]interface{}{
+ "error": err.Error(),
+ "sender": string(raw.Sender),
+ })
+ }
+ }
+
+ logger.DebugCF("onebot", "Normalized message event", map[string]interface{}{
+ "message_type": raw.MessageType,
+ "user_id": userID,
+ "group_id": groupID,
+ "message_id": messageID,
+ "content_len": len(content),
+ "nickname": sender.Nickname,
+ })
+
+ return &oneBotEvent{
+ PostType: raw.PostType,
+ MessageType: raw.MessageType,
+ SubType: raw.SubType,
+ MessageID: messageID,
+ UserID: userID,
+ GroupID: groupID,
+ Content: content,
+ RawContent: raw.RawMessage,
+ IsBotMentioned: isBotMentioned,
+ Sender: sender,
+ SelfID: selfID,
+ Time: ts,
+ MetaEventType: raw.MetaEventType,
+ }, nil
+}
+
+func (c *OneBotChannel) handleMetaEvent(raw *oneBotRawEvent) {
+ switch raw.MetaEventType {
+ case "lifecycle":
+ logger.InfoCF("onebot", "Lifecycle event", map[string]interface{}{
+ "sub_type": raw.SubType,
+ })
+ case "heartbeat":
+ logger.DebugC("onebot", "Heartbeat received")
+ default:
+ logger.DebugCF("onebot", "Unknown meta_event_type", map[string]interface{}{
+ "meta_event_type": raw.MetaEventType,
+ })
+ }
+}
+
+func (c *OneBotChannel) handleMessage(evt *oneBotEvent) {
+ if c.isDuplicate(evt.MessageID) {
+ logger.DebugCF("onebot", "Duplicate message, skipping", map[string]interface{}{
+ "message_id": evt.MessageID,
+ })
+ return
+ }
+
+ content := evt.Content
+ if content == "" {
+ logger.DebugCF("onebot", "Received empty message, ignoring", map[string]interface{}{
+ "message_id": evt.MessageID,
+ })
+ return
+ }
+
+ senderID := strconv.FormatInt(evt.UserID, 10)
+ var chatID string
+
+ metadata := map[string]string{
+ "message_id": evt.MessageID,
+ }
+
+ switch evt.MessageType {
+ case "private":
+ chatID = "private:" + senderID
+ logger.InfoCF("onebot", "Received private message", map[string]interface{}{
+ "sender": senderID,
+ "message_id": evt.MessageID,
+ "length": len(content),
+ "content": truncate(content, 100),
+ })
+
+ case "group":
+ groupIDStr := strconv.FormatInt(evt.GroupID, 10)
+ chatID = "group:" + groupIDStr
+ metadata["group_id"] = groupIDStr
+
+ senderUserID, _ := parseJSONInt64(evt.Sender.UserID)
+ if senderUserID > 0 {
+ metadata["sender_user_id"] = strconv.FormatInt(senderUserID, 10)
+ }
+
+ if evt.Sender.Card != "" {
+ metadata["sender_name"] = evt.Sender.Card
+ } else if evt.Sender.Nickname != "" {
+ metadata["sender_name"] = evt.Sender.Nickname
+ }
+
+ triggered, strippedContent := c.checkGroupTrigger(content, evt.IsBotMentioned)
+ if !triggered {
+ logger.DebugCF("onebot", "Group message ignored (no trigger)", map[string]interface{}{
+ "sender": senderID,
+ "group": groupIDStr,
+ "is_mentioned": evt.IsBotMentioned,
+ "content": truncate(content, 100),
+ })
+ return
+ }
+ content = strippedContent
+
+ logger.InfoCF("onebot", "Received group message", map[string]interface{}{
+ "sender": senderID,
+ "group": groupIDStr,
+ "message_id": evt.MessageID,
+ "is_mentioned": evt.IsBotMentioned,
+ "length": len(content),
+ "content": truncate(content, 100),
+ })
+
+ default:
+ logger.WarnCF("onebot", "Unknown message type, cannot route", map[string]interface{}{
+ "type": evt.MessageType,
+ "message_id": evt.MessageID,
+ "user_id": evt.UserID,
+ })
+ return
+ }
+
+ if evt.Sender.Nickname != "" {
+ metadata["nickname"] = evt.Sender.Nickname
+ }
+
+ logger.DebugCF("onebot", "Forwarding message to bus", map[string]interface{}{
+ "sender_id": senderID,
+ "chat_id": chatID,
+ "content": truncate(content, 100),
+ })
+
+ c.HandleMessage(senderID, chatID, content, []string{}, metadata)
+}
+
+func (c *OneBotChannel) isDuplicate(messageID string) bool {
+ if messageID == "" || messageID == "0" {
+ return false
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ if _, exists := c.dedup[messageID]; exists {
+ return true
+ }
+
+ if old := c.dedupRing[c.dedupIdx]; old != "" {
+ delete(c.dedup, old)
+ }
+ c.dedupRing[c.dedupIdx] = messageID
+ c.dedup[messageID] = struct{}{}
+ c.dedupIdx = (c.dedupIdx + 1) % len(c.dedupRing)
+
+ return false
+}
+
+func truncate(s string, n int) string {
+ runes := []rune(s)
+ if len(runes) <= n {
+ return s
+ }
+ return string(runes[:n]) + "..."
+}
+
+func (c *OneBotChannel) checkGroupTrigger(content string, isBotMentioned bool) (triggered bool, strippedContent string) {
+ if isBotMentioned {
+ return true, strings.TrimSpace(content)
+ }
+
+ for _, prefix := range c.config.GroupTriggerPrefix {
+ if prefix == "" {
+ continue
+ }
+ if strings.HasPrefix(content, prefix) {
+ return true, strings.TrimSpace(strings.TrimPrefix(content, prefix))
+ }
+ }
+
+ return false, content
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 6af9438a5..03dfe3133 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -77,6 +77,7 @@ type ChannelsConfig struct {
DingTalk DingTalkConfig `json:"dingtalk"`
Slack SlackConfig `json:"slack"`
LINE LINEConfig `json:"line"`
+ OneBot OneBotConfig `json:"onebot"`
}
type WhatsAppConfig struct {
@@ -145,6 +146,15 @@ type LINEConfig struct {
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_LINE_ALLOW_FROM"`
}
+type OneBotConfig struct {
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_ONEBOT_ENABLED"`
+ WSUrl string `json:"ws_url" env:"PICOCLAW_CHANNELS_ONEBOT_WS_URL"`
+ AccessToken string `json:"access_token" env:"PICOCLAW_CHANNELS_ONEBOT_ACCESS_TOKEN"`
+ ReconnectInterval int `json:"reconnect_interval" env:"PICOCLAW_CHANNELS_ONEBOT_RECONNECT_INTERVAL"`
+ GroupTriggerPrefix []string `json:"group_trigger_prefix" env:"PICOCLAW_CHANNELS_ONEBOT_GROUP_TRIGGER_PREFIX"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_ONEBOT_ALLOW_FROM"`
+}
+
type HeartbeatConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
@@ -265,6 +275,14 @@ func DefaultConfig() *Config {
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
},
+ OneBot: OneBotConfig{
+ Enabled: false,
+ WSUrl: "ws://127.0.0.1:3001",
+ AccessToken: "",
+ ReconnectInterval: 5,
+ GroupTriggerPrefix: []string{},
+ AllowFrom: FlexibleStringSlice{},
+ },
},
Providers: ProvidersConfig{
Anthropic: ProviderConfig{},
From 1fcf3fde0f6e3d89ad753f05e413ab82ee7e62ee Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 14 Feb 2026 17:23:18 +0800
Subject: [PATCH 05/46] style: fix format
---
pkg/channels/onebot.go | 55 +++++++++++++++++++++---------------------
1 file changed, 27 insertions(+), 28 deletions(-)
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index fe6edbeea..7f48ef6c0 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -18,19 +18,18 @@ import (
type OneBotChannel struct {
*BaseChannel
- config config.OneBotConfig
- conn *websocket.Conn
- ctx context.Context
- cancel context.CancelFunc
- dedup map[string]struct{}
- dedupRing []string
- dedupIdx int
- mu sync.Mutex
- writeMu sync.Mutex
- echoCounter int64
+ config config.OneBotConfig
+ conn *websocket.Conn
+ ctx context.Context
+ cancel context.CancelFunc
+ dedup map[string]struct{}
+ dedupRing []string
+ dedupIdx int
+ mu sync.Mutex
+ writeMu sync.Mutex
+ echoCounter int64
}
-
type oneBotRawEvent struct {
PostType string `json:"post_type"`
MessageType string `json:"message_type"`
@@ -44,9 +43,9 @@ type oneBotRawEvent struct {
SelfID json.RawMessage `json:"self_id"`
Time json.RawMessage `json:"time"`
MetaEventType string `json:"meta_event_type"`
- Echo string `json:"echo"`
- RetCode json.RawMessage `json:"retcode"`
- Status string `json:"status"`
+ Echo string `json:"echo"`
+ RetCode json.RawMessage `json:"retcode"`
+ Status string `json:"status"`
}
type oneBotSender struct {
@@ -56,19 +55,19 @@ type oneBotSender struct {
}
type oneBotEvent struct {
- PostType string
- MessageType string
- SubType string
- MessageID string
- UserID int64
- GroupID int64
- Content string
- RawContent string
- IsBotMentioned bool
- Sender oneBotSender
- SelfID int64
- Time int64
- MetaEventType string
+ PostType string
+ MessageType string
+ SubType string
+ MessageID string
+ UserID int64
+ GroupID int64
+ Content string
+ RawContent string
+ IsBotMentioned bool
+ Sender oneBotSender
+ SelfID int64
+ Time int64
+ MetaEventType string
}
type oneBotAPIRequest struct {
@@ -264,7 +263,7 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter
return "", nil, fmt.Errorf("invalid user ID in chatID: %s", chatID)
}
return "send_private_msg", oneBotSendPrivateMsgParams{
- UserID: userID,
+ UserID: userID,
Message: msg.Content,
}, nil
}
From f7421128a0074fe7577ada0614a2eb58afe3d082 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 14 Feb 2026 17:46:47 +0800
Subject: [PATCH 06/46] style: remove unnecessary log messages in WebSocket
listener
---
pkg/channels/onebot.go | 2 --
1 file changed, 2 deletions(-)
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index 7f48ef6c0..ae563b831 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -280,11 +280,9 @@ func (c *OneBotChannel) buildSendRequest(msg bus.OutboundMessage) (string, inter
}
func (c *OneBotChannel) listen() {
- logger.InfoC("onebot", "WebSocket listener started")
for {
select {
case <-c.ctx.Done():
- logger.InfoC("onebot", "WebSocket listener stopping (context cancelled)")
return
default:
c.mu.Lock()
From 773ce9bcb6ba6a9cd59d0ff51053b2e31d610912 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 14 Feb 2026 19:58:19 +0800
Subject: [PATCH 07/46] fix: update BotStatus type in oneBotRawEvent and adjust
status checks
---
pkg/channels/onebot.go | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index ae563b831..dadc9bc02 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -45,7 +45,12 @@ type oneBotRawEvent struct {
MetaEventType string `json:"meta_event_type"`
Echo string `json:"echo"`
RetCode json.RawMessage `json:"retcode"`
- Status string `json:"status"`
+ Status BotStatus `json:"status"`
+}
+
+type BotStatus struct {
+ Online bool `json:"online"`
+ Good bool `json:"good"`
}
type oneBotSender struct {
@@ -322,7 +327,7 @@ func (c *OneBotChannel) listen() {
continue
}
- if raw.Echo != "" || raw.Status != "" {
+ if raw.Echo != "" || raw.Status.Online || raw.Status.Good {
logger.DebugCF("onebot", "Received API response, skipping", map[string]interface{}{
"echo": raw.Echo,
"status": raw.Status,
From 60c3d96b5e5dbe155e2e4fdafea0a25bd78579fe Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 14 Feb 2026 20:02:30 +0800
Subject: [PATCH 08/46] style: fix alignment of fields in BotStatus struct
---
pkg/channels/onebot.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index dadc9bc02..674acf520 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -50,7 +50,7 @@ type oneBotRawEvent struct {
type BotStatus struct {
Online bool `json:"online"`
- Good bool `json:"good"`
+ Good bool `json:"good"`
}
type oneBotSender struct {
From 8cff6cf3123c71bacf0ee534352a3cac55e2b3f0 Mon Sep 17 00:00:00 2001
From: Hoshina
Date: Sat, 14 Feb 2026 20:25:55 +0800
Subject: [PATCH 09/46] fix: improve connection handling in OneBotChannel
---
pkg/channels/onebot.go | 13 ++++++++++---
1 file changed, 10 insertions(+), 3 deletions(-)
diff --git a/pkg/channels/onebot.go b/pkg/channels/onebot.go
index 674acf520..5d97fab9c 100644
--- a/pkg/channels/onebot.go
+++ b/pkg/channels/onebot.go
@@ -116,13 +116,20 @@ func (c *OneBotChannel) Start(ctx context.Context) error {
c.ctx, c.cancel = context.WithCancel(ctx)
if err := c.connect(); err != nil {
- return fmt.Errorf("failed to connect to OneBot: %w", err)
+ logger.WarnCF("onebot", "Initial connection failed, will retry in background", map[string]interface{}{
+ "error": err.Error(),
+ })
+ } else {
+ go c.listen()
}
- go c.listen()
-
if c.config.ReconnectInterval > 0 {
go c.reconnectLoop()
+ } else {
+ // If reconnect is disabled but initial connection failed, we cannot recover
+ if c.conn == nil {
+ return fmt.Errorf("failed to connect to OneBot and reconnect is disabled")
+ }
}
c.setRunning(true)
From a24cbd43855b19c4bb7defe1774e6dc17ff4c6db Mon Sep 17 00:00:00 2001
From: Artem Yadelskyi
Date: Sat, 14 Feb 2026 14:44:19 +0200
Subject: [PATCH 10/46] feat(docker): Added curl for Docker image
---
.gitignore | 6 +++++-
Dockerfile | 4 ++--
go.mod | 2 +-
go.sum | 4 ++--
4 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/.gitignore b/.gitignore
index 6ba411758..6a61e26b1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,8 @@ coverage.html
# Ralph workspace
ralph/
.ralph/
-tasks/
\ No newline at end of file
+tasks/
+
+# Editors
+.vscode/
+.idea/
diff --git a/Dockerfile b/Dockerfile
index 8db995545..5587198fe 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -18,9 +18,9 @@ RUN make build
# ============================================================
# Stage 2: Minimal runtime image
# ============================================================
-FROM alpine:3.21
+FROM alpine:3.23
-RUN apk add --no-cache ca-certificates tzdata
+RUN apk add --no-cache ca-certificates tzdata curl
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
diff --git a/go.mod b/go.mod
index f4c233ea8..78683203f 100644
--- a/go.mod
+++ b/go.mod
@@ -13,7 +13,7 @@ require (
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mymmrac/telego v1.6.0
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1
- github.com/openai/openai-go/v3 v3.21.0
+ github.com/openai/openai-go/v3 v3.22.0
github.com/slack-go/slack v0.17.3
github.com/tencent-connect/botgo v0.2.1
golang.org/x/oauth2 v0.35.0
diff --git a/go.sum b/go.sum
index 9174d2889..80e40a33f 100644
--- a/go.sum
+++ b/go.sum
@@ -92,8 +92,8 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8=
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU=
-github.com/openai/openai-go/v3 v3.21.0 h1:3GpIR/W4q/v1uUOVuK3zYtQiF3DnRrZag/sxbtvEdtc=
-github.com/openai/openai-go/v3 v3.21.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
+github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys=
+github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
From a9557aa0736ecc8bc6836b59a8d1e21fe73546fa Mon Sep 17 00:00:00 2001
From: lxowalle
Date: Sun, 15 Feb 2026 00:16:42 +0800
Subject: [PATCH 11/46] feat: Support installing built-in AGENT files and
skills during `picoclaw onboard`
---
.gitignore | 1 +
Dockerfile | 6 +-
Makefile | 31 +--
cmd/picoclaw/main.go | 224 +++++-------------
workspace/AGENT.md | 12 +
workspace/IDENTITY.md | 56 +++++
workspace/SOUL.md | 17 ++
workspace/USER.md | 21 ++
workspace/memory/MEMORY.md | 21 ++
{skills => workspace/skills}/github/SKILL.md | 0
.../skills}/hardware/SKILL.md | 0
.../hardware/references/board-pinout.md | 0
.../hardware/references/common-devices.md | 0
.../skills}/skill-creator/SKILL.md | 0
.../skills}/summarize/SKILL.md | 0
{skills => workspace/skills}/tmux/SKILL.md | 0
.../skills}/tmux/scripts/find-sessions.sh | 0
.../skills}/tmux/scripts/wait-for-text.sh | 0
{skills => workspace/skills}/weather/SKILL.md | 0
19 files changed, 190 insertions(+), 199 deletions(-)
create mode 100644 workspace/AGENT.md
create mode 100644 workspace/IDENTITY.md
create mode 100644 workspace/SOUL.md
create mode 100644 workspace/USER.md
create mode 100644 workspace/memory/MEMORY.md
rename {skills => workspace/skills}/github/SKILL.md (100%)
rename {skills => workspace/skills}/hardware/SKILL.md (100%)
rename {skills => workspace/skills}/hardware/references/board-pinout.md (100%)
rename {skills => workspace/skills}/hardware/references/common-devices.md (100%)
rename {skills => workspace/skills}/skill-creator/SKILL.md (100%)
rename {skills => workspace/skills}/summarize/SKILL.md (100%)
rename {skills => workspace/skills}/tmux/SKILL.md (100%)
rename {skills => workspace/skills}/tmux/scripts/find-sessions.sh (100%)
rename {skills => workspace/skills}/tmux/scripts/wait-for-text.sh (100%)
rename {skills => workspace/skills}/weather/SKILL.md (100%)
diff --git a/.gitignore b/.gitignore
index 6ba411758..4e5a83f8a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ build/
*.out
/picoclaw
/picoclaw-test
+cmd/picoclaw/workspace
# Picoclaw specific
diff --git a/Dockerfile b/Dockerfile
index 8db995545..217de3124 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,12 +25,8 @@ RUN apk add --no-cache ca-certificates tzdata
# Copy binary
COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw
-# Copy builtin skills
-COPY --from=builder /src/skills /opt/picoclaw/skills
-
# Create picoclaw home directory
-RUN mkdir -p /root/.picoclaw/workspace/skills && \
- cp -r /opt/picoclaw/skills/* /root/.picoclaw/workspace/skills/ 2>/dev/null || true
+RUN /usr/local/bin/picoclaw onboard
ENTRYPOINT ["picoclaw"]
CMD ["gateway"]
diff --git a/Makefile b/Makefile
index 2defcce31..55144bf1e 100644
--- a/Makefile
+++ b/Makefile
@@ -67,6 +67,8 @@ all: build
build:
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
+ @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
+ @cp -r workspace ./$(CMD_DIR)/workspace 2>/dev/null || true
$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
@@ -74,6 +76,8 @@ build:
## build-all: Build picoclaw for all platforms
build-all:
@echo "Building for multiple platforms..."
+ @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
+ @cp -r workspace ./$(CMD_DIR)/workspace 2>/dev/null || true
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
@@ -89,35 +93,8 @@ install: build
@cp $(BUILD_DIR)/$(BINARY_NAME) $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@chmod +x $(INSTALL_BIN_DIR)/$(BINARY_NAME)
@echo "Installed binary to $(INSTALL_BIN_DIR)/$(BINARY_NAME)"
- @echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..."
- @mkdir -p $(WORKSPACE_SKILLS_DIR)
- @for skill in $(BUILTIN_SKILLS_DIR)/*/; do \
- if [ -d "$$skill" ]; then \
- skill_name=$$(basename "$$skill"); \
- if [ -f "$$skill/SKILL.md" ]; then \
- cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \
- echo " ✓ Installed skill: $$skill_name"; \
- fi; \
- fi; \
- done
@echo "Installation complete!"
-## install-skills: Install builtin skills to workspace
-install-skills:
- @echo "Installing builtin skills to $(WORKSPACE_SKILLS_DIR)..."
- @mkdir -p $(WORKSPACE_SKILLS_DIR)
- @for skill in $(BUILTIN_SKILLS_DIR)/*/; do \
- if [ -d "$$skill" ]; then \
- skill_name=$$(basename "$$skill"); \
- if [ -f "$$skill/SKILL.md" ]; then \
- mkdir -p $(WORKSPACE_SKILLS_DIR)/$$skill_name; \
- cp -r "$$skill" $(WORKSPACE_SKILLS_DIR); \
- echo " ✓ Installed skill: $$skill_name"; \
- fi; \
- fi; \
- done
- @echo "Skills installation complete!"
-
## uninstall: Remove picoclaw from system
uninstall:
@echo "Uninstalling $(BINARY_NAME)..."
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 86463c661..191ff8239 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -11,12 +11,14 @@ import (
"context"
"fmt"
"io"
+ "io/fs"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"time"
+ "embed"
"github.com/chzyer/readline"
"github.com/sipeed/picoclaw/pkg/agent"
@@ -36,6 +38,10 @@ import (
"github.com/sipeed/picoclaw/pkg/voice"
)
+//go:embed workspace
+var embeddedFiles embed.FS
+
+
var (
version = "dev"
gitCommit string
@@ -208,6 +214,7 @@ func printHelp() {
fmt.Println(" version Show version information")
}
+
func onboard() {
configPath := getConfigPath()
@@ -229,10 +236,6 @@ func onboard() {
}
workspace := cfg.WorkspacePath()
- os.MkdirAll(workspace, 0755)
- os.MkdirAll(filepath.Join(workspace, "memory"), 0755)
- os.MkdirAll(filepath.Join(workspace, "skills"), 0755)
-
createWorkspaceTemplates(workspace)
fmt.Printf("%s picoclaw is ready!\n", logo)
@@ -242,170 +245,57 @@ func onboard() {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
+func copyEmbeddedToTarget(targetDir string) error {
+ // Ensure target directory exists
+ if err := os.MkdirAll(targetDir, 0755); err != nil {
+ return fmt.Errorf("Failed to create target directory: %w", err)
+ }
+
+ // Walk through all files in embed.FS
+ err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+
+ // Skip directories
+ if d.IsDir() {
+ return nil
+ }
+
+ // Read embedded file
+ data, err := embeddedFiles.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
+ }
+
+ new_path, err := filepath.Rel("workspace", path)
+ if err != nil {
+ return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
+ }
+
+ // Build target file path
+ targetPath := filepath.Join(targetDir, new_path)
+
+ // Ensure target file's directory exists
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
+ }
+
+ // Write file
+ if err := os.WriteFile(targetPath, data, 0644); err != nil {
+ return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
+ }
+
+ return nil
+ })
+
+ return err
+}
+
func createWorkspaceTemplates(workspace string) {
- templates := map[string]string{
- "AGENTS.md": `# Agent Instructions
-
-You are a helpful AI assistant. Be concise, accurate, and friendly.
-
-## Guidelines
-
-- Always explain what you're doing before taking actions
-- Ask for clarification when request is ambiguous
-- Use tools to help accomplish tasks
-- Remember important information in your memory files
-- Be proactive and helpful
-- Learn from user feedback
-`,
- "SOUL.md": `# Soul
-
-I am picoclaw, a lightweight AI assistant powered by AI.
-
-## Personality
-
-- Helpful and friendly
-- Concise and to the point
-- Curious and eager to learn
-- Honest and transparent
-
-## Values
-
-- Accuracy over speed
-- User privacy and safety
-- Transparency in actions
-- Continuous improvement
-`,
- "USER.md": `# User
-
-Information about user goes here.
-
-## Preferences
-
-- Communication style: (casual/formal)
-- Timezone: (your timezone)
-- Language: (your preferred language)
-
-## Personal Information
-
-- Name: (optional)
-- Location: (optional)
-- Occupation: (optional)
-
-## Learning Goals
-
-- What the user wants to learn from AI
-- Preferred interaction style
-- Areas of interest
-`,
- "IDENTITY.md": `# Identity
-
-## Name
-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.)
-- Enable easy customization through skills system
-- Run on minimal hardware ($10 boards, <10MB RAM)
-
-## Capabilities
-
-- Web search and content fetching
-- File system operations (read, write, edit)
-- Shell command execution
-- Multi-channel messaging (Telegram, WhatsApp, Feishu)
-- Skill-based extensibility
-- Memory and context management
-
-## Philosophy
-
-- Simplicity over complexity
-- Performance over features
-- User control and privacy
-- Transparent operation
-- Community-driven development
-
-## Goals
-
-- Provide a fast, lightweight AI assistant
-- Support offline-first operation where possible
-- Enable easy customization and extension
-- Maintain high quality responses
-- Run efficiently on constrained hardware
-
-## License
-MIT License - Free and open source
-
-## Repository
-https://github.com/sipeed/picoclaw
-
-## Contact
-Issues: https://github.com/sipeed/picoclaw/issues
-Discussions: https://github.com/sipeed/picoclaw/discussions
-
----
-
-"Every bit helps, every bit matters."
-- Picoclaw
-`,
- }
-
- for filename, content := range templates {
- filePath := filepath.Join(workspace, filename)
- if _, err := os.Stat(filePath); os.IsNotExist(err) {
- os.WriteFile(filePath, []byte(content), 0644)
- fmt.Printf(" Created %s\n", filename)
- }
- }
-
- memoryDir := filepath.Join(workspace, "memory")
- os.MkdirAll(memoryDir, 0755)
- memoryFile := filepath.Join(memoryDir, "MEMORY.md")
- if _, err := os.Stat(memoryFile); os.IsNotExist(err) {
- memoryContent := `# Long-term Memory
-
-This file stores important information that should persist across sessions.
-
-## User Information
-
-(Important facts about user)
-
-## Preferences
-
-(User preferences learned over time)
-
-## Important Notes
-
-(Things to remember)
-
-## Configuration
-
-- Model preferences
-- Channel settings
-- Skills enabled
-`
- os.WriteFile(memoryFile, []byte(memoryContent), 0644)
- fmt.Println(" Created memory/MEMORY.md")
-
- skillsDir := filepath.Join(workspace, "skills")
- if _, err := os.Stat(skillsDir); os.IsNotExist(err) {
- os.MkdirAll(skillsDir, 0755)
- fmt.Println(" Created skills/")
- }
- }
-
- for filename, content := range templates {
- filePath := filepath.Join(workspace, filename)
- if _, err := os.Stat(filePath); os.IsNotExist(err) {
- os.WriteFile(filePath, []byte(content), 0644)
- fmt.Printf(" Created %s\n", filename)
- }
+ err := copyEmbeddedToTarget(workspace)
+ if err != nil {
+ fmt.Printf("Error copying workspace templates: %v\n", err)
}
}
diff --git a/workspace/AGENT.md b/workspace/AGENT.md
new file mode 100644
index 000000000..5f5fa6480
--- /dev/null
+++ b/workspace/AGENT.md
@@ -0,0 +1,12 @@
+# Agent Instructions
+
+You are a helpful AI assistant. Be concise, accurate, and friendly.
+
+## Guidelines
+
+- Always explain what you're doing before taking actions
+- Ask for clarification when request is ambiguous
+- Use tools to help accomplish tasks
+- Remember important information in your memory files
+- Be proactive and helpful
+- Learn from user feedback
\ No newline at end of file
diff --git a/workspace/IDENTITY.md b/workspace/IDENTITY.md
new file mode 100644
index 000000000..dabb0e14b
--- /dev/null
+++ b/workspace/IDENTITY.md
@@ -0,0 +1,56 @@
+# Identity
+
+## Name
+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.)
+- Enable easy customization through skills system
+- Run on minimal hardware ($10 boards, <10MB RAM)
+
+## Capabilities
+
+- Web search and content fetching
+- File system operations (read, write, edit)
+- Shell command execution
+- Multi-channel messaging (Telegram, WhatsApp, Feishu)
+- Skill-based extensibility
+- Memory and context management
+
+## Philosophy
+
+- Simplicity over complexity
+- Performance over features
+- User control and privacy
+- Transparent operation
+- Community-driven development
+
+## Goals
+
+- Provide a fast, lightweight AI assistant
+- Support offline-first operation where possible
+- Enable easy customization and extension
+- Maintain high quality responses
+- Run efficiently on constrained hardware
+
+## License
+MIT License - Free and open source
+
+## Repository
+https://github.com/sipeed/picoclaw
+
+## Contact
+Issues: https://github.com/sipeed/picoclaw/issues
+Discussions: https://github.com/sipeed/picoclaw/discussions
+
+---
+
+"Every bit helps, every bit matters."
+- Picoclaw
\ No newline at end of file
diff --git a/workspace/SOUL.md b/workspace/SOUL.md
new file mode 100644
index 000000000..0be8834f5
--- /dev/null
+++ b/workspace/SOUL.md
@@ -0,0 +1,17 @@
+# Soul
+
+I am picoclaw, a lightweight AI assistant powered by AI.
+
+## Personality
+
+- Helpful and friendly
+- Concise and to the point
+- Curious and eager to learn
+- Honest and transparent
+
+## Values
+
+- Accuracy over speed
+- User privacy and safety
+- Transparency in actions
+- Continuous improvement
\ No newline at end of file
diff --git a/workspace/USER.md b/workspace/USER.md
new file mode 100644
index 000000000..91398a019
--- /dev/null
+++ b/workspace/USER.md
@@ -0,0 +1,21 @@
+# User
+
+Information about user goes here.
+
+## Preferences
+
+- Communication style: (casual/formal)
+- Timezone: (your timezone)
+- Language: (your preferred language)
+
+## Personal Information
+
+- Name: (optional)
+- Location: (optional)
+- Occupation: (optional)
+
+## Learning Goals
+
+- What the user wants to learn from AI
+- Preferred interaction style
+- Areas of interest
\ No newline at end of file
diff --git a/workspace/memory/MEMORY.md b/workspace/memory/MEMORY.md
new file mode 100644
index 000000000..265271db9
--- /dev/null
+++ b/workspace/memory/MEMORY.md
@@ -0,0 +1,21 @@
+# Long-term Memory
+
+This file stores important information that should persist across sessions.
+
+## User Information
+
+(Important facts about user)
+
+## Preferences
+
+(User preferences learned over time)
+
+## Important Notes
+
+(Things to remember)
+
+## Configuration
+
+- Model preferences
+- Channel settings
+- Skills enabled
\ No newline at end of file
diff --git a/skills/github/SKILL.md b/workspace/skills/github/SKILL.md
similarity index 100%
rename from skills/github/SKILL.md
rename to workspace/skills/github/SKILL.md
diff --git a/skills/hardware/SKILL.md b/workspace/skills/hardware/SKILL.md
similarity index 100%
rename from skills/hardware/SKILL.md
rename to workspace/skills/hardware/SKILL.md
diff --git a/skills/hardware/references/board-pinout.md b/workspace/skills/hardware/references/board-pinout.md
similarity index 100%
rename from skills/hardware/references/board-pinout.md
rename to workspace/skills/hardware/references/board-pinout.md
diff --git a/skills/hardware/references/common-devices.md b/workspace/skills/hardware/references/common-devices.md
similarity index 100%
rename from skills/hardware/references/common-devices.md
rename to workspace/skills/hardware/references/common-devices.md
diff --git a/skills/skill-creator/SKILL.md b/workspace/skills/skill-creator/SKILL.md
similarity index 100%
rename from skills/skill-creator/SKILL.md
rename to workspace/skills/skill-creator/SKILL.md
diff --git a/skills/summarize/SKILL.md b/workspace/skills/summarize/SKILL.md
similarity index 100%
rename from skills/summarize/SKILL.md
rename to workspace/skills/summarize/SKILL.md
diff --git a/skills/tmux/SKILL.md b/workspace/skills/tmux/SKILL.md
similarity index 100%
rename from skills/tmux/SKILL.md
rename to workspace/skills/tmux/SKILL.md
diff --git a/skills/tmux/scripts/find-sessions.sh b/workspace/skills/tmux/scripts/find-sessions.sh
similarity index 100%
rename from skills/tmux/scripts/find-sessions.sh
rename to workspace/skills/tmux/scripts/find-sessions.sh
diff --git a/skills/tmux/scripts/wait-for-text.sh b/workspace/skills/tmux/scripts/wait-for-text.sh
similarity index 100%
rename from skills/tmux/scripts/wait-for-text.sh
rename to workspace/skills/tmux/scripts/wait-for-text.sh
diff --git a/skills/weather/SKILL.md b/workspace/skills/weather/SKILL.md
similarity index 100%
rename from skills/weather/SKILL.md
rename to workspace/skills/weather/SKILL.md
From dbf2739783ccebc7b66a3b7f013551faca0c62d7 Mon Sep 17 00:00:00 2001
From: lxowalle
Date: Sun, 15 Feb 2026 02:26:42 +0800
Subject: [PATCH 12/46] fix: Remove the waiting animation for Telegram replies
to reduce the risk of being rate-limited.
---
pkg/channels/telegram.go | 27 ++-------------------------
1 file changed, 2 insertions(+), 25 deletions(-)
diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go
index 0934dbd08..b14b1632e 100644
--- a/pkg/channels/telegram.go
+++ b/pkg/channels/telegram.go
@@ -320,37 +320,14 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat
}
}
- // Create new context for thinking animation with timeout
- thinkCtx, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
+ // Create cancel function for thinking state
+ _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute)
c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel})
pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭"))
if err == nil {
pID := pMsg.MessageID
c.placeholders.Store(chatIDStr, pID)
-
- go func(cid int64, mid int) {
- dots := []string{".", "..", "..."}
- emotes := []string{"💭", "🤔", "☁️"}
- i := 0
- ticker := time.NewTicker(2000 * time.Millisecond)
- defer ticker.Stop()
- for {
- select {
- case <-thinkCtx.Done():
- return
- case <-ticker.C:
- i++
- text := fmt.Sprintf("Thinking%s %s", dots[i%len(dots)], emotes[i%len(emotes)])
- _, editErr := c.bot.EditMessageText(thinkCtx, tu.EditMessageText(tu.ID(chatID), mid, text))
- if editErr != nil {
- logger.DebugCF("telegram", "Failed to edit thinking message", map[string]interface{}{
- "error": editErr.Error(),
- })
- }
- }
- }
- }(chatID, pID)
}
metadata := map[string]string{
From 7140e73d464285c79b6c71d9d26e1febde7c4b0a Mon Sep 17 00:00:00 2001
From: lxowalle
Date: Sun, 15 Feb 2026 03:07:12 +0800
Subject: [PATCH 13/46] * fix fmt bug
---
cmd/picoclaw/main.go | 66 +++++++++++++++++++++-----------------------
1 file changed, 32 insertions(+), 34 deletions(-)
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 191ff8239..e424fb7d3 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -9,6 +9,7 @@ package main
import (
"bufio"
"context"
+ "embed"
"fmt"
"io"
"io/fs"
@@ -18,7 +19,6 @@ import (
"runtime"
"strings"
"time"
- "embed"
"github.com/chzyer/readline"
"github.com/sipeed/picoclaw/pkg/agent"
@@ -41,7 +41,6 @@ import (
//go:embed workspace
var embeddedFiles embed.FS
-
var (
version = "dev"
gitCommit string
@@ -214,7 +213,6 @@ func printHelp() {
fmt.Println(" version Show version information")
}
-
func onboard() {
configPath := getConfigPath()
@@ -246,50 +244,50 @@ func onboard() {
}
func copyEmbeddedToTarget(targetDir string) error {
- // Ensure target directory exists
- if err := os.MkdirAll(targetDir, 0755); err != nil {
- return fmt.Errorf("Failed to create target directory: %w", err)
- }
+ // Ensure target directory exists
+ if err := os.MkdirAll(targetDir, 0755); err != nil {
+ return fmt.Errorf("Failed to create target directory: %w", err)
+ }
- // Walk through all files in embed.FS
- err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
+ // Walk through all files in embed.FS
+ err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
- // Skip directories
- if d.IsDir() {
- return nil
- }
+ // Skip directories
+ if d.IsDir() {
+ return nil
+ }
- // Read embedded file
- data, err := embeddedFiles.ReadFile(path)
- if err != nil {
- return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
- }
+ // Read embedded file
+ data, err := embeddedFiles.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
+ }
new_path, err := filepath.Rel("workspace", path)
if err != nil {
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
}
- // Build target file path
- targetPath := filepath.Join(targetDir, new_path)
+ // Build target file path
+ targetPath := filepath.Join(targetDir, new_path)
- // Ensure target file's directory exists
- if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
- return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
- }
+ // Ensure target file's directory exists
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
+ }
- // Write file
- if err := os.WriteFile(targetPath, data, 0644); err != nil {
- return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
- }
+ // Write file
+ if err := os.WriteFile(targetPath, data, 0644); err != nil {
+ return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
+ }
- return nil
- })
+ return nil
+ })
- return err
+ return err
}
func createWorkspaceTemplates(workspace string) {
From 5faa67b77d9ceba411eb8de2627a43319a17b3dd Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 05:23:42 +0800
Subject: [PATCH 14/46] feat: add `Github Copilot` provider
---
go.mod | 5 ++
go.sum | 4 ++
pkg/config/config.go | 4 ++
pkg/providers/github_copilot_provider.go | 85 ++++++++++++++++++++++++
4 files changed, 98 insertions(+)
create mode 100644 pkg/providers/github_copilot_provider.go
diff --git a/go.mod b/go.mod
index f4c233ea8..206f1aafd 100644
--- a/go.mod
+++ b/go.mod
@@ -19,12 +19,16 @@ require (
golang.org/x/oauth2 v0.35.0
)
+
+
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
+ github.com/github/copilot-sdk/go v0.1.23
+ github.com/google/jsonschema-go v0.4.2 // indirect
github.com/go-resty/resty/v2 v2.17.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/grbit/go-json v0.11.0 // indirect
@@ -43,4 +47,5 @@ require (
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
+
)
diff --git a/go.sum b/go.sum
index 9174d2889..8f9575122 100644
--- a/go.sum
+++ b/go.sum
@@ -32,6 +32,8 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
+github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw=
+github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0=
github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w=
github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q=
github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4=
@@ -56,6 +58,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
+github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
diff --git a/pkg/config/config.go b/pkg/config/config.go
index bbfa2e40b..eafdb006e 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -168,6 +168,7 @@ type ProvidersConfig struct {
Moonshot ProviderConfig `json:"moonshot"`
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"`
+ GitHubCopilot ProviderConfig `json:"github_copilot"`
}
type ProviderConfig struct {
@@ -175,8 +176,11 @@ type ProviderConfig struct {
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
}
+
+
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
new file mode 100644
index 000000000..67fbdd568
--- /dev/null
+++ b/pkg/providers/github_copilot_provider.go
@@ -0,0 +1,85 @@
+package providers
+
+import (
+ "context"
+ "fmt"
+
+ json "encoding/json"
+ copilot "github.com/github/copilot-sdk/go"
+)
+
+type GitHubCopilotProvider struct {
+ uri string
+ connectMode string // `stdio` or `grpc``
+
+ session *copilot.Session
+}
+
+func NewGitHubCopilotProvider(uri string, connectMode string, model string) *GitHubCopilotProvider {
+
+ var session *copilot.Session
+
+ switch connectMode {
+
+ case "stdio":
+
+ case "grpc":
+ client := copilot.NewClient(&copilot.ClientOptions{
+ CLIUrl: uri,
+ })
+ if err := client.Start(context.Background()); err != nil {
+ fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
+ }
+ defer client.Stop()
+ session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
+ Model: model,
+ Hooks: &copilot.SessionHooks{
+
+ },
+ })
+ }
+
+ return &GitHubCopilotProvider{
+ uri: uri,
+ connectMode: connectMode,
+
+ session: session,
+ }
+}
+
+// Chat sends a chat request to GitHub Copilot
+func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
+ type tempMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ }
+ out := make([]tempMessage, 0, len(messages))
+
+ for _, msg := range messages {
+ out = append(out, tempMessage{
+ Role: msg.Role,
+ Content: msg.Content,
+ })
+ }
+
+ fullcontent,_ := json.Marshal(out)
+
+
+
+
+ content,_ := p.session.Send(ctx,copilot.MessageOptions{
+ Prompt: string(fullcontent),
+ })
+
+ return &LLMResponse{
+ FinishReason : "stop",
+ Content: content,
+ }, nil
+
+}
+
+
+func (p *GitHubCopilotProvider) GetDefaultModel() string {
+
+ return "gpt-4.1"
+}
From 0aab8d8afcb69659c663dbcbff2fd0ec77cb4208 Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 06:10:00 +0800
Subject: [PATCH 15/46] feat:add `github_copilot` to providers factory
---
pkg/providers/http_provider.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 6fcbd3055..013f787d1 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -314,7 +314,17 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
model = "deepseek-chat"
}
}
+ case "github_copilot", "copilot":
+ if cfg.Providers.GitHubCopilot.APIBase != "" {
+ apiBase = cfg.Providers.GitHubCopilot.APIBase
+ }else {
+ apiBase = "localhost:4321" // no `http://` beacause grpc mode`
+ }
+ return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil
+
}
+
+
}
// Fallback: detect provider from model name
From 6c392c338758053b928a92bff86769fbd6160c3a Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 06:11:07 +0800
Subject: [PATCH 16/46] fix: openclaw crash because of empty config
---
pkg/providers/github_copilot_provider.go | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 67fbdd568..4774ac703 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -4,8 +4,10 @@ import (
"context"
"fmt"
- json "encoding/json"
+ json "encoding/json"
+
copilot "github.com/github/copilot-sdk/go"
+
)
type GitHubCopilotProvider struct {
@@ -18,7 +20,9 @@ type GitHubCopilotProvider struct {
func NewGitHubCopilotProvider(uri string, connectMode string, model string) *GitHubCopilotProvider {
var session *copilot.Session
-
+ if connectMode == "" {
+ connectMode = "grpc"
+ }
switch connectMode {
case "stdio":
@@ -37,6 +41,7 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git
},
})
+
}
return &GitHubCopilotProvider{
@@ -66,10 +71,11 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to
-
+ fmt.Println("[DEBUG] call p.session.Send")
content,_ := p.session.Send(ctx,copilot.MessageOptions{
Prompt: string(fullcontent),
})
+ fmt.Println("[DEBUG] end cal")
return &LLMResponse{
FinishReason : "stop",
From ecbe31599efae9f622a6c4a502503be005b59eab Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 06:30:31 +0800
Subject: [PATCH 17/46] chore: remove redundant debug output
---
pkg/providers/github_copilot_provider.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 4774ac703..a6cd20d9f 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -71,11 +71,11 @@ func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, to
- fmt.Println("[DEBUG] call p.session.Send")
+
content,_ := p.session.Send(ctx,copilot.MessageOptions{
Prompt: string(fullcontent),
})
- fmt.Println("[DEBUG] end cal")
+
return &LLMResponse{
FinishReason : "stop",
From 7a9659971df856f9dd91b302dcb33ebde5d0e4a3 Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 06:55:20 +0800
Subject: [PATCH 18/46] make fmt
---
pkg/config/config.go | 34 ++++++------
pkg/providers/github_copilot_provider.go | 66 +++++++++++-------------
pkg/providers/http_provider.go | 9 ++--
3 files changed, 49 insertions(+), 60 deletions(-)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index eafdb006e..b6d4e0b7f 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -157,30 +157,28 @@ type DevicesConfig struct {
}
type ProvidersConfig struct {
- Anthropic ProviderConfig `json:"anthropic"`
- OpenAI ProviderConfig `json:"openai"`
- OpenRouter ProviderConfig `json:"openrouter"`
- Groq ProviderConfig `json:"groq"`
- Zhipu ProviderConfig `json:"zhipu"`
- VLLM ProviderConfig `json:"vllm"`
- Gemini ProviderConfig `json:"gemini"`
- Nvidia ProviderConfig `json:"nvidia"`
- Moonshot ProviderConfig `json:"moonshot"`
- ShengSuanYun ProviderConfig `json:"shengsuanyun"`
- DeepSeek ProviderConfig `json:"deepseek"`
+ Anthropic ProviderConfig `json:"anthropic"`
+ OpenAI ProviderConfig `json:"openai"`
+ OpenRouter ProviderConfig `json:"openrouter"`
+ Groq ProviderConfig `json:"groq"`
+ Zhipu ProviderConfig `json:"zhipu"`
+ VLLM ProviderConfig `json:"vllm"`
+ Gemini ProviderConfig `json:"gemini"`
+ Nvidia ProviderConfig `json:"nvidia"`
+ Moonshot ProviderConfig `json:"moonshot"`
+ ShengSuanYun ProviderConfig `json:"shengsuanyun"`
+ DeepSeek ProviderConfig `json:"deepseek"`
GitHubCopilot ProviderConfig `json:"github_copilot"`
}
type ProviderConfig struct {
- APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
- APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
- Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
- AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
- ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
+ APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
+ APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
+ Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
+ AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
+ ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc`
}
-
-
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index a6cd20d9f..39a3f7e9f 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -7,21 +7,20 @@ import (
json "encoding/json"
copilot "github.com/github/copilot-sdk/go"
-
)
type GitHubCopilotProvider struct {
uri string
connectMode string // `stdio` or `grpc``
-
- session *copilot.Session
+
+ session *copilot.Session
}
func NewGitHubCopilotProvider(uri string, connectMode string, model string) *GitHubCopilotProvider {
var session *copilot.Session
- if connectMode == "" {
- connectMode = "grpc"
+ if connectMode == "" {
+ connectMode = "grpc"
}
switch connectMode {
@@ -37,55 +36,48 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git
defer client.Stop()
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
Model: model,
- Hooks: &copilot.SessionHooks{
-
- },
+ Hooks: &copilot.SessionHooks{},
})
-
+
}
return &GitHubCopilotProvider{
uri: uri,
connectMode: connectMode,
-
- session: session,
+
+ session: session,
}
}
// Chat sends a chat request to GitHub Copilot
func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
- type tempMessage struct {
- Role string `json:"role"`
- Content string `json:"content"`
- }
- out := make([]tempMessage, 0, len(messages))
+ type tempMessage struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ }
+ out := make([]tempMessage, 0, len(messages))
- for _, msg := range messages {
- out = append(out, tempMessage{
- Role: msg.Role,
- Content: msg.Content,
- })
- }
+ for _, msg := range messages {
+ out = append(out, tempMessage{
+ Role: msg.Role,
+ Content: msg.Content,
+ })
+ }
- fullcontent,_ := json.Marshal(out)
-
+ fullcontent, _ := json.Marshal(out)
+ content, _ := p.session.Send(ctx, copilot.MessageOptions{
+ Prompt: string(fullcontent),
+ })
+
+ return &LLMResponse{
+ FinishReason: "stop",
+ Content: content,
+ }, nil
-
- content,_ := p.session.Send(ctx,copilot.MessageOptions{
- Prompt: string(fullcontent),
- })
-
-
- return &LLMResponse{
- FinishReason : "stop",
- Content: content,
- }, nil
-
}
-
func (p *GitHubCopilotProvider) GetDefaultModel() string {
- return "gpt-4.1"
+ return "gpt-4.1"
}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 013f787d1..1bd440fc0 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -317,13 +317,12 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
case "github_copilot", "copilot":
if cfg.Providers.GitHubCopilot.APIBase != "" {
apiBase = cfg.Providers.GitHubCopilot.APIBase
- }else {
- apiBase = "localhost:4321" // no `http://` beacause grpc mode`
- }
+ } else {
+ apiBase = "localhost:4321" // no `http://` beacause grpc mode`
+ }
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil
-
+
}
-
}
From f6d6221c09d67ce3c395c9281b17d8547cc61bbb Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 07:00:40 +0800
Subject: [PATCH 19/46] make fmt
---
pkg/providers/http_provider.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 1bd440fc0..d723ba872 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -318,7 +318,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
if cfg.Providers.GitHubCopilot.APIBase != "" {
apiBase = cfg.Providers.GitHubCopilot.APIBase
} else {
- apiBase = "localhost:4321" // no `http://` beacause grpc mode`
+ apiBase = "localhost:4321"
}
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil
From 5a6ad37dab6edf3068fea334c407ad234dee8382 Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 07:34:12 +0800
Subject: [PATCH 20/46] code fmt
---
cmd/picoclaw/main.go | 66 +++++++++++++++++-----------------
pkg/providers/http_provider.go | 2 +-
2 files changed, 33 insertions(+), 35 deletions(-)
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index 191ff8239..e424fb7d3 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -9,6 +9,7 @@ package main
import (
"bufio"
"context"
+ "embed"
"fmt"
"io"
"io/fs"
@@ -18,7 +19,6 @@ import (
"runtime"
"strings"
"time"
- "embed"
"github.com/chzyer/readline"
"github.com/sipeed/picoclaw/pkg/agent"
@@ -41,7 +41,6 @@ import (
//go:embed workspace
var embeddedFiles embed.FS
-
var (
version = "dev"
gitCommit string
@@ -214,7 +213,6 @@ func printHelp() {
fmt.Println(" version Show version information")
}
-
func onboard() {
configPath := getConfigPath()
@@ -246,50 +244,50 @@ func onboard() {
}
func copyEmbeddedToTarget(targetDir string) error {
- // Ensure target directory exists
- if err := os.MkdirAll(targetDir, 0755); err != nil {
- return fmt.Errorf("Failed to create target directory: %w", err)
- }
+ // Ensure target directory exists
+ if err := os.MkdirAll(targetDir, 0755); err != nil {
+ return fmt.Errorf("Failed to create target directory: %w", err)
+ }
- // Walk through all files in embed.FS
- err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
- if err != nil {
- return err
- }
+ // Walk through all files in embed.FS
+ err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
- // Skip directories
- if d.IsDir() {
- return nil
- }
+ // Skip directories
+ if d.IsDir() {
+ return nil
+ }
- // Read embedded file
- data, err := embeddedFiles.ReadFile(path)
- if err != nil {
- return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
- }
+ // Read embedded file
+ data, err := embeddedFiles.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
+ }
new_path, err := filepath.Rel("workspace", path)
if err != nil {
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
}
- // Build target file path
- targetPath := filepath.Join(targetDir, new_path)
+ // Build target file path
+ targetPath := filepath.Join(targetDir, new_path)
- // Ensure target file's directory exists
- if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
- return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
- }
+ // Ensure target file's directory exists
+ if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
+ return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
+ }
- // Write file
- if err := os.WriteFile(targetPath, data, 0644); err != nil {
- return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
- }
+ // Write file
+ if err := os.WriteFile(targetPath, data, 0644); err != nil {
+ return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
+ }
- return nil
- })
+ return nil
+ })
- return err
+ return err
}
func createWorkspaceTemplates(workspace string) {
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index d723ba872..52a380239 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -318,7 +318,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
if cfg.Providers.GitHubCopilot.APIBase != "" {
apiBase = cfg.Providers.GitHubCopilot.APIBase
} else {
- apiBase = "localhost:4321"
+ apiBase = "localhost:4321"
}
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil
From 9eb1a53fb856993e2a4be853773e51e94be156e4 Mon Sep 17 00:00:00 2001
From: lxowalle
Date: Sun, 15 Feb 2026 07:43:46 +0800
Subject: [PATCH 21/46] fix: PR workflow execution failure
---
.github/workflows/pr.yml | 6 ++++++
Makefile | 25 ++++++++++++++++++-------
cmd/picoclaw/main.go | 1 +
3 files changed, 25 insertions(+), 7 deletions(-)
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index 35ad87ac5..fac7597ea 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -32,6 +32,9 @@ jobs:
with:
go-version-file: go.mod
+ - name: Run go generate
+ run: go generate ./...
+
- name: Run go vet
run: go vet ./...
@@ -47,6 +50,9 @@ jobs:
with:
go-version-file: go.mod
+ - name: Run go generate
+ run: go generate ./...
+
- name: Run go test
run: go test ./...
diff --git a/Makefile b/Makefile
index 55144bf1e..058fdb790 100644
--- a/Makefile
+++ b/Makefile
@@ -63,21 +63,24 @@ BINARY_PATH=$(BUILD_DIR)/$(BINARY_NAME)-$(PLATFORM)-$(ARCH)
# Default target
all: build
+## generate: Run generate
+generate:
+ @echo "Run generate..."
+ @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
+ @$(GO) generate ./...
+ @echo "Run generate complete"
+
## build: Build the picoclaw binary for current platform
-build:
+build: generate
@echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..."
@mkdir -p $(BUILD_DIR)
- @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
- @cp -r workspace ./$(CMD_DIR)/workspace 2>/dev/null || true
- $(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
+ @$(GO) build $(GOFLAGS) $(LDFLAGS) -o $(BINARY_PATH) ./$(CMD_DIR)
@echo "Build complete: $(BINARY_PATH)"
@ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME)
## build-all: Build picoclaw for all platforms
-build-all:
+build-all: generate
@echo "Building for multiple platforms..."
- @rm -r ./$(CMD_DIR)/workspace 2>/dev/null || true
- @cp -r workspace ./$(CMD_DIR)/workspace 2>/dev/null || true
@mkdir -p $(BUILD_DIR)
GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR)
GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR)
@@ -116,6 +119,14 @@ clean:
@rm -rf $(BUILD_DIR)
@echo "Clean complete"
+## fmt: Format Go code
+vet:
+ @$(GO) vet ./...
+
+## fmt: Format Go code
+test:
+ @$(GO) test ./...
+
## fmt: Format Go code
fmt:
@$(GO) fmt ./...
diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go
index e424fb7d3..2129662d7 100644
--- a/cmd/picoclaw/main.go
+++ b/cmd/picoclaw/main.go
@@ -38,6 +38,7 @@ import (
"github.com/sipeed/picoclaw/pkg/voice"
)
+//go:generate cp -r ../../workspace .
//go:embed workspace
var embeddedFiles embed.FS
From 0a4bf32e81fb8bb93b78c76b5dbbce5a84cb8b30 Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 07:54:04 +0800
Subject: [PATCH 22/46] try after workflow fix
---
pkg/providers/github_copilot_provider.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index 39a3f7e9f..ec9b1b68d 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -25,7 +25,7 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git
switch connectMode {
case "stdio":
-
+ //todo
case "grpc":
client := copilot.NewClient(&copilot.ClientOptions{
CLIUrl: uri,
From 4b886b65739a91a771a38d793b89b2c2c250a50f Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 08:02:23 +0800
Subject: [PATCH 23/46] fix: not used result
---
pkg/providers/github_copilot_provider.go | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go
index ec9b1b68d..5058819f5 100644
--- a/pkg/providers/github_copilot_provider.go
+++ b/pkg/providers/github_copilot_provider.go
@@ -16,7 +16,7 @@ type GitHubCopilotProvider struct {
session *copilot.Session
}
-func NewGitHubCopilotProvider(uri string, connectMode string, model string) *GitHubCopilotProvider {
+func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) {
var session *copilot.Session
if connectMode == "" {
@@ -31,7 +31,7 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git
CLIUrl: uri,
})
if err := client.Start(context.Background()); err != nil {
- fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
+ return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details")
}
defer client.Stop()
session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{
@@ -44,9 +44,8 @@ func NewGitHubCopilotProvider(uri string, connectMode string, model string) *Git
return &GitHubCopilotProvider{
uri: uri,
connectMode: connectMode,
-
- session: session,
- }
+ session: session,
+ }, nil
}
// Chat sends a chat request to GitHub Copilot
From 16e5a0295386c034b8812c16831dda17b7a209b1 Mon Sep 17 00:00:00 2001
From: Lixeer <1612655510@qq.com>
Date: Sun, 15 Feb 2026 08:06:29 +0800
Subject: [PATCH 24/46] fix(http_provider): Remove extra parameter from
CreateProvider function.
---
pkg/providers/http_provider.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 52a380239..0bea16d6a 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -320,7 +320,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
} else {
apiBase = "localhost:4321"
}
- return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model), nil
+ return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
}
From 0a88ff08172fa93aef1f3c781e96c0150cfdd136 Mon Sep 17 00:00:00 2001
From: xiaoen <2768753269@qq.com>
Date: Sun, 15 Feb 2026 00:28:36 +0800
Subject: [PATCH 25/46] fix: resolve multiple bugs from code review #116
Fixes four issues identified in the community code review:
- Session persistence broken on Windows: session keys like
"telegram:123456" contain ':', which is illegal in Windows
filenames. filepath.Base() strips drive-letter prefixes on Windows,
causing Save() to silently fail. Added sanitizeFilename() to
replace invalid chars in the filename while keeping the original
key in the JSON payload.
- HTTP client with no timeout: HTTPProvider used Timeout: 0 (infinite
wait), which can hang the entire agent if an API endpoint becomes
unresponsive. Set a 120s safety timeout.
- Slack AllowFrom type mismatch: SlackConfig used plain []string
while every other channel uses FlexibleStringSlice, so numeric
user IDs in Slack config would fail to parse.
- Token estimation wrong for CJK: estimateTokens() divided byte
length by 4, but CJK characters are 3 bytes each, causing ~3x
overestimation and premature summarization. Switched to
utf8.RuneCountInString() / 3 for better cross-language accuracy.
Also added unit tests for the session filename sanitization.
Ref #116
---
pkg/agent/loop.go | 6 ++-
pkg/config/config.go | 10 ++---
pkg/providers/http_provider.go | 3 +-
pkg/session/manager.go | 20 +++++++--
pkg/session/manager_test.go | 74 ++++++++++++++++++++++++++++++++++
5 files changed, 103 insertions(+), 10 deletions(-)
create mode 100644 pkg/session/manager_test.go
diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go
index 73e837119..f3dd94090 100644
--- a/pkg/agent/loop.go
+++ b/pkg/agent/loop.go
@@ -16,6 +16,7 @@ import (
"sync"
"sync/atomic"
"time"
+ "unicode/utf8"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
@@ -768,10 +769,13 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa
}
// estimateTokens estimates the number of tokens in a message list.
+// Uses rune count instead of byte length so that CJK and other multi-byte
+// characters are not over-counted (a Chinese character is 3 bytes but roughly
+// one token).
func (al *AgentLoop) estimateTokens(messages []providers.Message) int {
total := 0
for _, m := range messages {
- total += len(m.Content) / 4 // Simple heuristic: 4 chars per token
+ total += utf8.RuneCountInString(m.Content) / 3
}
return total
}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index bbfa2e40b..70afb27f9 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -130,10 +130,10 @@ type DingTalkConfig struct {
}
type SlackConfig struct {
- Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
- BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
- AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
- AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
+ Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
+ BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
+ AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
+ AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
}
type LINEConfig struct {
@@ -261,7 +261,7 @@ func DefaultConfig() *Config {
Enabled: false,
BotToken: "",
AppToken: "",
- AllowFrom: []string{},
+ AllowFrom: FlexibleStringSlice{},
},
LINE: LINEConfig{
Enabled: false,
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 6fcbd3055..b6a17e6b2 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -15,6 +15,7 @@ import (
"net/http"
"net/url"
"strings"
+ "time"
"github.com/sipeed/picoclaw/pkg/auth"
"github.com/sipeed/picoclaw/pkg/config"
@@ -28,7 +29,7 @@ type HTTPProvider struct {
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
client := &http.Client{
- Timeout: 0,
+ Timeout: 120 * time.Second,
}
if proxy != "" {
diff --git a/pkg/session/manager.go b/pkg/session/manager.go
index 193ad2b30..9981d4901 100644
--- a/pkg/session/manager.go
+++ b/pkg/session/manager.go
@@ -145,13 +145,27 @@ func (sm *SessionManager) TruncateHistory(key string, keepLast int) {
session.Updated = time.Now()
}
+// sanitizeFilename converts a session key into a cross-platform safe filename.
+// Session keys use "channel:chatID" (e.g. "telegram:123456") but ':' is the
+// volume separator on Windows, so filepath.Base would misinterpret the key.
+// We replace it with '_'. The original key is preserved inside the JSON file,
+// so loadSessions still maps back to the right in-memory key.
+func sanitizeFilename(key string) string {
+ return strings.ReplaceAll(key, ":", "_")
+}
+
func (sm *SessionManager) Save(key string) error {
if sm.storage == "" {
return nil
}
- // Validate key to avoid invalid filenames and path traversal.
- if key == "" || key == "." || key == ".." || key != filepath.Base(key) || strings.Contains(key, "/") || strings.Contains(key, "\\") {
+ filename := sanitizeFilename(key)
+
+ // filepath.IsLocal rejects empty names, "..", absolute paths, and
+ // OS-reserved device names (NUL, COM1 … on Windows).
+ // The extra checks reject "." and any directory separators so that
+ // the session file is always written directly inside sm.storage.
+ if filename == "." || !filepath.IsLocal(filename) || strings.ContainsAny(filename, `/\`) {
return os.ErrInvalid
}
@@ -182,7 +196,7 @@ func (sm *SessionManager) Save(key string) error {
return err
}
- sessionPath := filepath.Join(sm.storage, key+".json")
+ sessionPath := filepath.Join(sm.storage, filename+".json")
tmpFile, err := os.CreateTemp(sm.storage, "session-*.tmp")
if err != nil {
return err
diff --git a/pkg/session/manager_test.go b/pkg/session/manager_test.go
new file mode 100644
index 000000000..5ef5f4349
--- /dev/null
+++ b/pkg/session/manager_test.go
@@ -0,0 +1,74 @@
+package session
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestSanitizeFilename(t *testing.T) {
+ tests := []struct {
+ input string
+ expected string
+ }{
+ {"simple", "simple"},
+ {"telegram:123456", "telegram_123456"},
+ {"discord:987654321", "discord_987654321"},
+ {"slack:C01234", "slack_C01234"},
+ {"no-colons-here", "no-colons-here"},
+ {"multiple:colons:here", "multiple_colons_here"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ got := sanitizeFilename(tt.input)
+ if got != tt.expected {
+ t.Errorf("sanitizeFilename(%q) = %q, want %q", tt.input, got, tt.expected)
+ }
+ })
+ }
+}
+
+func TestSave_WithColonInKey(t *testing.T) {
+ tmpDir := t.TempDir()
+ sm := NewSessionManager(tmpDir)
+
+ // Create a session with a key containing colon (typical channel session key).
+ key := "telegram:123456"
+ sm.GetOrCreate(key)
+ sm.AddMessage(key, "user", "hello")
+
+ // Save should succeed even though the key contains ':'
+ if err := sm.Save(key); err != nil {
+ t.Fatalf("Save(%q) failed: %v", key, err)
+ }
+
+ // The file on disk should use sanitized name.
+ expectedFile := filepath.Join(tmpDir, "telegram_123456.json")
+ if _, err := os.Stat(expectedFile); os.IsNotExist(err) {
+ t.Fatalf("expected session file %s to exist", expectedFile)
+ }
+
+ // Load into a fresh manager and verify the session round-trips.
+ sm2 := NewSessionManager(tmpDir)
+ history := sm2.GetHistory(key)
+ if len(history) != 1 {
+ t.Fatalf("expected 1 message after reload, got %d", len(history))
+ }
+ if history[0].Content != "hello" {
+ t.Errorf("expected message content %q, got %q", "hello", history[0].Content)
+ }
+}
+
+func TestSave_RejectsPathTraversal(t *testing.T) {
+ tmpDir := t.TempDir()
+ sm := NewSessionManager(tmpDir)
+
+ badKeys := []string{"", ".", "..", "foo/bar", "foo\\bar"}
+ for _, key := range badKeys {
+ sm.GetOrCreate(key)
+ if err := sm.Save(key); err == nil {
+ t.Errorf("Save(%q) should have failed but didn't", key)
+ }
+ }
+}
From 9a3f3611c30e6108707ace0af56b9b35158f0a75 Mon Sep 17 00:00:00 2001
From: Meng Zhuo
Date: Sun, 15 Feb 2026 09:50:01 +0800
Subject: [PATCH 26/46] ci: init goreleaser
---
.gitignore | 3 +++
.goreleaser.yaml | 61 ++++++++++++++++++++++++++++++++++++++++++++++++
Dockerfile | 2 +-
3 files changed, 65 insertions(+), 1 deletion(-)
create mode 100644 .goreleaser.yaml
diff --git a/.gitignore b/.gitignore
index 3e5cee80f..ce30d749e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,3 +41,6 @@ tasks/
# Editors
.vscode/
.idea/
+
+# Added by goreleaser init:
+dist/
diff --git a/.goreleaser.yaml b/.goreleaser.yaml
new file mode 100644
index 000000000..a2c158331
--- /dev/null
+++ b/.goreleaser.yaml
@@ -0,0 +1,61 @@
+# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
+# vim: set ts=2 sw=2 tw=0 fo=cnqoj
+version: 2
+
+before:
+ hooks:
+ - go mod tidy
+
+builds:
+ - env:
+ - CGO_ENABLED=0
+ goos:
+ - linux
+ - windows
+ - darwin
+ - freebsd
+ goarch:
+ - amd64
+ - arm64
+ - riscv64
+ - s390x
+ - mips64
+ - arm
+ main: ./cmd/picoclaw
+ ignore:
+ - goos: windows
+ goarch: arm
+
+archives:
+ - formats: [tar.gz]
+ # this name template makes the OS and Arch compatible with the results of `uname`.
+ name_template: >-
+ {{ .ProjectName }}_
+ {{- title .Os }}_
+ {{- if eq .Arch "amd64" }}x86_64
+ {{- else if eq .Arch "386" }}i386
+ {{- else }}{{ .Arch }}{{ end }}
+ {{- if .Arm }}v{{ .Arm }}{{ end }}
+ # use zip for windows archives
+ format_overrides:
+ - goos: windows
+ formats: [zip]
+
+changelog:
+ sort: asc
+ filters:
+ exclude:
+ - "^docs:"
+ - "^test:"
+
+upx:
+ - enabled: true
+ compress: best
+ lzma: true
+
+release:
+ footer: >-
+
+ ---
+
+ Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
diff --git a/Dockerfile b/Dockerfile
index 59c28a1aa..433d962f2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,7 +1,7 @@
# ============================================================
# Stage 1: Build the picoclaw binary
# ============================================================
-FROM golang:1.25.7-alpine AS builder
+FROM golang:1.26.0-alpine AS builder
RUN apk add --no-cache git make
From 214b201bfaf2577a6ea53896c1bf626a63af41c6 Mon Sep 17 00:00:00 2001
From: zepan
Date: Sun, 15 Feb 2026 20:49:58 +0800
Subject: [PATCH 27/46] update wechat group qrcode
---
README.md | 2 +-
README.zh.md | 4 ++--
assets/wechat.png | Bin 148820 -> 143766 bytes
3 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index a61a1abf4..091af2811 100644
--- a/README.md
+++ b/README.md
@@ -44,7 +44,7 @@
> * **NO CRYPTO:** PicoClaw has **NO** official token/coin. All claims on `pump.fun` or other trading platforms are **SCAMS**.
> * **OFFICIAL DOMAIN:** The **ONLY** official website is **[picoclaw.io](https://picoclaw.io)**, and company website is **[sipeed.com](https://sipeed.com)**
> * **Warning:** Many `.ai/.org/.com/.net/...` domains are registered by third parties.
->
+> * **Warning:** picoclaw is in early development now and may have unresolved network security issues. Do not deploy to production environments before the v1.0 release.
## 📢 News
diff --git a/README.zh.md b/README.zh.md
index f94abce88..5a1c3c50b 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -45,8 +45,8 @@
> * **无加密货币 (NO CRYPTO):** PicoClaw **没有** 发行任何官方代币、Token 或虚拟货币。所有在 `pump.fun` 或其他交易平台上的相关声称均为 **诈骗**。
> * **官方域名:** 唯一的官方网站是 **[picoclaw.io](https://picoclaw.io)**,公司官网是 **[sipeed.com](https://sipeed.com)**。
> * **警惕:** 许多 `.ai/.org/.com/.net/...` 后缀的域名被第三方抢注,请勿轻信。
->
->
+> * **注意:** picoclaw正在初期的快速功能开发阶段,可能有尚未修复的网络安全问题,在1.0正式版发布前,请不要将其部署到生产环境中
+
## 📢 新闻 (News)
diff --git a/assets/wechat.png b/assets/wechat.png
index 0f97fa3eed73bd214bb34cc945b771b00c64a878..d62c8d09db29a2bf18e191f6f1df264e1d9c5331 100644
GIT binary patch
literal 143766
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