* First commit

This commit is contained in:
lxowalle
2026-02-04 19:06:13 +08:00
commit e17693b17c
57 changed files with 7994 additions and 0 deletions

158
pkg/agent/context.go Normal file
View File

@@ -0,0 +1,158 @@
package agent
import (
"fmt"
"os"
"path/filepath"
"time"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/skills"
)
type ContextBuilder struct {
workspace string
skillsLoader *skills.SkillsLoader
}
func NewContextBuilder(workspace string) *ContextBuilder {
builtinSkillsDir := filepath.Join(filepath.Dir(workspace), "picoclaw", "skills")
return &ContextBuilder{
workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, builtinSkillsDir),
}
}
func (cb *ContextBuilder) BuildSystemPrompt() string {
now := time.Now().Format("2006-01-02 15:04 (Monday)")
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant. You have access to tools that allow you to:
- Read, write, and edit files
- Execute shell commands
- Search the web and fetch web pages
- Send messages to users on chat channels
- Spawn subagents for complex background tasks
## Current Time
%s
## Workspace
Your workspace is at: %s
- Memory files: %s/memory/MEMORY.md
- Daily notes: %s/memory/2006-01-02.md
- Custom skills: %s/skills/{skill-name}/SKILL.md
## Weather Information
When users ask about weather, use the web_fetch tool with wttr.in URLs:
- Current weather: https://wttr.in/{city}?format=j1
- Beijing: https://wttr.in/Beijing?format=j1
- Shanghai: https://wttr.in/Shanghai?format=j1
- New York: https://wttr.in/New_York?format=j1
- London: https://wttr.in/London?format=j1
- Tokyo: https://wttr.in/Tokyo?format=j1
IMPORTANT: When responding to direct questions or conversations, reply directly with your text response.
Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp).
For normal conversation, just respond with text - do not call the message tool.
Always be helpful, accurate, and concise. When using tools, explain what you're doing.
When remembering something, write to %s/memory/MEMORY.md`,
now, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath)
}
func (cb *ContextBuilder) LoadBootstrapFiles() string {
bootstrapFiles := []string{
"AGENTS.md",
"SOUL.md",
"USER.md",
"TOOLS.md",
"IDENTITY.md",
"MEMORY.md",
}
var result string
for _, filename := range bootstrapFiles {
filePath := filepath.Join(cb.workspace, filename)
if data, err := os.ReadFile(filePath); err == nil {
result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
}
}
return result
}
func (cb *ContextBuilder) BuildMessages(history []providers.Message, currentMessage string, media []string) []providers.Message {
messages := []providers.Message{}
systemPrompt := cb.BuildSystemPrompt()
bootstrapContent := cb.LoadBootstrapFiles()
if bootstrapContent != "" {
systemPrompt += "\n\n" + bootstrapContent
}
skillsSummary := cb.skillsLoader.BuildSkillsSummary()
if skillsSummary != "" {
systemPrompt += "\n\n## Available Skills\n\n" + skillsSummary
}
skillsContent := cb.loadSkills()
if skillsContent != "" {
systemPrompt += "\n\n" + skillsContent
}
messages = append(messages, providers.Message{
Role: "system",
Content: systemPrompt,
})
messages = append(messages, history...)
messages = append(messages, providers.Message{
Role: "user",
Content: currentMessage,
})
return messages
}
func (cb *ContextBuilder) AddToolResult(messages []providers.Message, toolCallID, toolName, result string) []providers.Message {
messages = append(messages, providers.Message{
Role: "tool",
Content: result,
ToolCallID: toolCallID,
})
return messages
}
func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, content string, toolCalls []map[string]interface{}) []providers.Message {
msg := providers.Message{
Role: "assistant",
Content: content,
}
if len(toolCalls) > 0 {
messages = append(messages, msg)
}
return messages
}
func (cb *ContextBuilder) loadSkills() string {
allSkills := cb.skillsLoader.ListSkills(true)
if len(allSkills) == 0 {
return ""
}
var skillNames []string
for _, s := range allSkills {
skillNames = append(skillNames, s.Name)
}
content := cb.skillsLoader.LoadSkillsForContext(skillNames)
if content == "" {
return ""
}
return "# Skill Definitions\n\n" + content
}

193
pkg/agent/loop.go Normal file
View File

@@ -0,0 +1,193 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package agent
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/tools"
)
type AgentLoop struct {
bus *bus.MessageBus
provider providers.LLMProvider
workspace string
model string
maxIterations int
sessions *session.SessionManager
contextBuilder *ContextBuilder
tools *tools.ToolRegistry
running bool
}
func NewAgentLoop(cfg *config.Config, bus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
workspace := cfg.WorkspacePath()
os.MkdirAll(workspace, 0755)
toolsRegistry := tools.NewToolRegistry()
toolsRegistry.Register(&tools.ReadFileTool{})
toolsRegistry.Register(&tools.WriteFileTool{})
toolsRegistry.Register(&tools.ListDirTool{})
toolsRegistry.Register(tools.NewExecTool(workspace))
braveAPIKey := cfg.Tools.Web.Search.APIKey
toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
toolsRegistry.Register(tools.NewWebFetchTool(50000))
sessionsManager := session.NewSessionManager(filepath.Join(filepath.Dir(cfg.WorkspacePath()), "sessions"))
return &AgentLoop{
bus: bus,
provider: provider,
workspace: workspace,
model: cfg.Agents.Defaults.Model,
maxIterations: cfg.Agents.Defaults.MaxToolIterations,
sessions: sessionsManager,
contextBuilder: NewContextBuilder(workspace),
tools: toolsRegistry,
running: false,
}
}
func (al *AgentLoop) Run(ctx context.Context) error {
al.running = true
for al.running {
select {
case <-ctx.Done():
return nil
default:
msg, ok := al.bus.ConsumeInbound(ctx)
if !ok {
continue
}
response, err := al.processMessage(ctx, msg)
if err != nil {
response = fmt.Sprintf("Error processing message: %v", err)
}
if response != "" {
al.bus.PublishOutbound(bus.OutboundMessage{
Channel: msg.Channel,
ChatID: msg.ChatID,
Content: response,
})
}
}
}
return nil
}
func (al *AgentLoop) Stop() {
al.running = false
}
func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) {
msg := bus.InboundMessage{
Channel: "cli",
SenderID: "user",
ChatID: "direct",
Content: content,
SessionKey: sessionKey,
}
return al.processMessage(ctx, msg)
}
func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
messages := al.contextBuilder.BuildMessages(
al.sessions.GetHistory(msg.SessionKey),
msg.Content,
nil,
)
iteration := 0
var finalContent string
for iteration < al.maxIterations {
iteration++
toolDefs := al.tools.GetDefinitions()
providerToolDefs := make([]providers.ToolDefinition, 0, len(toolDefs))
for _, td := range toolDefs {
providerToolDefs = append(providerToolDefs, providers.ToolDefinition{
Type: td["type"].(string),
Function: providers.ToolFunctionDefinition{
Name: td["function"].(map[string]interface{})["name"].(string),
Description: td["function"].(map[string]interface{})["description"].(string),
Parameters: td["function"].(map[string]interface{})["parameters"].(map[string]interface{}),
},
})
}
response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
"max_tokens": 8192,
"temperature": 0.7,
})
if err != nil {
return "", fmt.Errorf("LLM call failed: %w", err)
}
if len(response.ToolCalls) == 0 {
finalContent = response.Content
break
}
assistantMsg := providers.Message{
Role: "assistant",
Content: response.Content,
}
for _, tc := range response.ToolCalls {
argumentsJSON, _ := json.Marshal(tc.Arguments)
assistantMsg.ToolCalls = append(assistantMsg.ToolCalls, providers.ToolCall{
ID: tc.ID,
Type: "function",
Function: &providers.FunctionCall{
Name: tc.Name,
Arguments: string(argumentsJSON),
},
})
}
messages = append(messages, assistantMsg)
for _, tc := range response.ToolCalls {
result, err := al.tools.Execute(ctx, tc.Name, tc.Arguments)
if err != nil {
result = fmt.Sprintf("Error: %v", err)
}
toolResultMsg := providers.Message{
Role: "tool",
Content: result,
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
}
}
if finalContent == "" {
finalContent = "I've completed processing but have no response to give."
}
al.sessions.AddMessage(msg.SessionKey, "user", msg.Content)
al.sessions.AddMessage(msg.SessionKey, "assistant", finalContent)
al.sessions.Save(al.sessions.GetOrCreate(msg.SessionKey))
return finalContent, nil
}

65
pkg/bus/bus.go Normal file
View File

@@ -0,0 +1,65 @@
package bus
import (
"context"
"sync"
)
type MessageBus struct {
inbound chan InboundMessage
outbound chan OutboundMessage
handlers map[string]MessageHandler
mu sync.RWMutex
}
func NewMessageBus() *MessageBus {
return &MessageBus{
inbound: make(chan InboundMessage, 100),
outbound: make(chan OutboundMessage, 100),
handlers: make(map[string]MessageHandler),
}
}
func (mb *MessageBus) PublishInbound(msg InboundMessage) {
mb.inbound <- msg
}
func (mb *MessageBus) ConsumeInbound(ctx context.Context) (InboundMessage, bool) {
select {
case msg := <-mb.inbound:
return msg, true
case <-ctx.Done():
return InboundMessage{}, false
}
}
func (mb *MessageBus) PublishOutbound(msg OutboundMessage) {
mb.outbound <- msg
}
func (mb *MessageBus) SubscribeOutbound(ctx context.Context) (OutboundMessage, bool) {
select {
case msg := <-mb.outbound:
return msg, true
case <-ctx.Done():
return OutboundMessage{}, false
}
}
func (mb *MessageBus) RegisterHandler(channel string, handler MessageHandler) {
mb.mu.Lock()
defer mb.mu.Unlock()
mb.handlers[channel] = handler
}
func (mb *MessageBus) GetHandler(channel string) (MessageHandler, bool) {
mb.mu.RLock()
defer mb.mu.RUnlock()
handler, ok := mb.handlers[channel]
return handler, ok
}
func (mb *MessageBus) Close() {
close(mb.inbound)
close(mb.outbound)
}

19
pkg/bus/types.go Normal file
View File

@@ -0,0 +1,19 @@
package bus
type InboundMessage struct {
Channel string `json:"channel"`
SenderID string `json:"sender_id"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
Media []string `json:"media,omitempty"`
SessionKey string `json:"session_key"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type OutboundMessage struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Content string `json:"content"`
}
type MessageHandler func(InboundMessage) error

77
pkg/channels/base.go Normal file
View File

@@ -0,0 +1,77 @@
package channels
import (
"context"
"github.com/sipeed/picoclaw/pkg/bus"
)
type Channel interface {
Name() string
Start(ctx context.Context) error
Stop(ctx context.Context) error
Send(ctx context.Context, msg bus.OutboundMessage) error
IsRunning() bool
IsAllowed(senderID string) bool
}
type BaseChannel struct {
config interface{}
bus *bus.MessageBus
running bool
name string
allowList []string
}
func NewBaseChannel(name string, config interface{}, bus *bus.MessageBus, allowList []string) *BaseChannel {
return &BaseChannel{
config: config,
bus: bus,
name: name,
allowList: allowList,
running: false,
}
}
func (c *BaseChannel) Name() string {
return c.name
}
func (c *BaseChannel) IsRunning() bool {
return c.running
}
func (c *BaseChannel) IsAllowed(senderID string) bool {
if len(c.allowList) == 0 {
return true
}
for _, allowed := range c.allowList {
if senderID == allowed {
return true
}
}
return false
}
func (c *BaseChannel) HandleMessage(senderID, chatID, content string, media []string, metadata map[string]string) {
if !c.IsAllowed(senderID) {
return
}
msg := bus.InboundMessage{
Channel: c.name,
SenderID: senderID,
ChatID: chatID,
Content: content,
Media: media,
Metadata: metadata,
}
c.bus.PublishInbound(msg)
}
func (c *BaseChannel) setRunning(running bool) {
c.running = running
}

138
pkg/channels/discord.go Normal file
View File

@@ -0,0 +1,138 @@
package channels
import (
"context"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
type DiscordChannel struct {
*BaseChannel
session *discordgo.Session
config config.DiscordConfig
}
func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordChannel, error) {
session, err := discordgo.New("Bot " + cfg.Token)
if err != nil {
return nil, fmt.Errorf("failed to create discord session: %w", err)
}
base := NewBaseChannel("discord", cfg, bus, cfg.AllowFrom)
return &DiscordChannel{
BaseChannel: base,
session: session,
config: cfg,
}, nil
}
func (c *DiscordChannel) Start(ctx context.Context) error {
logger.InfoC("discord", "Starting Discord bot")
c.session.AddHandler(c.handleMessage)
if err := c.session.Open(); err != nil {
return fmt.Errorf("failed to open discord session: %w", err)
}
c.setRunning(true)
botUser, err := c.session.User("@me")
if err != nil {
return fmt.Errorf("failed to get bot user: %w", err)
}
logger.InfoCF("discord", "Discord bot connected", map[string]interface{}{
"username": botUser.Username,
"user_id": botUser.ID,
})
return nil
}
func (c *DiscordChannel) Stop(ctx context.Context) error {
logger.InfoC("discord", "Stopping Discord bot")
c.setRunning(false)
if err := c.session.Close(); err != nil {
return fmt.Errorf("failed to close discord session: %w", err)
}
return nil
}
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("discord bot not running")
}
channelID := msg.ChatID
if channelID == "" {
return fmt.Errorf("channel ID is empty")
}
message := msg.Content
if _, err := c.session.ChannelMessageSend(channelID, message); err != nil {
return fmt.Errorf("failed to send discord message: %w", err)
}
return nil
}
func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
if m == nil || m.Author == nil {
return
}
if m.Author.ID == s.State.User.ID {
return
}
senderID := m.Author.ID
senderName := m.Author.Username
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
senderName += "#" + m.Author.Discriminator
}
content := m.Content
mediaPaths := []string{}
for _, attachment := range m.Attachments {
mediaPaths = append(mediaPaths, attachment.URL)
if content != "" {
content += "\n"
}
content += fmt.Sprintf("[attachment: %s]", attachment.URL)
}
if content == "" && len(mediaPaths) == 0 {
return
}
if content == "" {
content = "[media only]"
}
logger.DebugCF("discord", "Received message", map[string]interface{}{
"sender_name": senderName,
"sender_id": senderID,
"preview": truncateString(content, 50),
})
metadata := map[string]string{
"message_id": m.ID,
"user_id": senderID,
"username": m.Author.Username,
"display_name": senderName,
"guild_id": m.GuildID,
"channel_id": m.ChannelID,
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
}
c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata)
}

70
pkg/channels/feishu.go Normal file
View File

@@ -0,0 +1,70 @@
package channels
import (
"context"
"fmt"
"log"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
type FeishuChannel struct {
*BaseChannel
config config.FeishuConfig
}
func NewFeishuChannel(cfg config.FeishuConfig, bus *bus.MessageBus) (*FeishuChannel, error) {
base := NewBaseChannel("feishu", cfg, bus, cfg.AllowFrom)
return &FeishuChannel{
BaseChannel: base,
config: cfg,
}, nil
}
func (c *FeishuChannel) Start(ctx context.Context) error {
log.Println("Feishu channel started")
c.setRunning(true)
return nil
}
func (c *FeishuChannel) Stop(ctx context.Context) error {
log.Println("Feishu channel stopped")
c.setRunning(false)
return nil
}
func (c *FeishuChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("feishu channel not running")
}
htmlContent := markdownToFeishuCard(msg.Content)
log.Printf("Feishu send to %s: %s", msg.ChatID, truncateString(htmlContent, 100))
return nil
}
func (c *FeishuChannel) handleIncomingMessage(data map[string]interface{}) {
senderID, _ := data["sender_id"].(string)
chatID, _ := data["chat_id"].(string)
content, _ := data["content"].(string)
log.Printf("Feishu message from %s: %s...", senderID, truncateString(content, 50))
metadata := make(map[string]string)
if messageID, ok := data["message_id"].(string); ok {
metadata["message_id"] = messageID
}
if userName, ok := data["sender_name"].(string); ok {
metadata["sender_name"] = userName
}
c.HandleMessage(senderID, chatID, content, nil, metadata)
}
func markdownToFeishuCard(markdown string) string {
return markdown
}

243
pkg/channels/maixcam.go Normal file
View File

@@ -0,0 +1,243 @@
package channels
import (
"context"
"encoding/json"
"fmt"
"net"
"sync"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
type MaixCamChannel struct {
*BaseChannel
config config.MaixCamConfig
listener net.Listener
clients map[net.Conn]bool
clientsMux sync.RWMutex
running bool
}
type MaixCamMessage struct {
Type string `json:"type"`
Tips string `json:"tips"`
Timestamp float64 `json:"timestamp"`
Data map[string]interface{} `json:"data"`
}
func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamChannel, error) {
base := NewBaseChannel("maixcam", cfg, bus, cfg.AllowFrom)
return &MaixCamChannel{
BaseChannel: base,
config: cfg,
clients: make(map[net.Conn]bool),
running: false,
}, nil
}
func (c *MaixCamChannel) Start(ctx context.Context) error {
logger.InfoC("maixcam", "Starting MaixCam channel server")
addr := fmt.Sprintf("%s:%d", c.config.Host, c.config.Port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", addr, err)
}
c.listener = listener
c.setRunning(true)
logger.InfoCF("maixcam", "MaixCam server listening", map[string]interface{}{
"host": c.config.Host,
"port": c.config.Port,
})
go c.acceptConnections(ctx)
return nil
}
func (c *MaixCamChannel) acceptConnections(ctx context.Context) {
logger.DebugC("maixcam", "Starting connection acceptor")
for {
select {
case <-ctx.Done():
logger.InfoC("maixcam", "Stopping connection acceptor")
return
default:
conn, err := c.listener.Accept()
if err != nil {
if c.running {
logger.ErrorCF("maixcam", "Failed to accept connection", map[string]interface{}{
"error": err.Error(),
})
}
return
}
logger.InfoCF("maixcam", "New connection from MaixCam device", map[string]interface{}{
"remote_addr": conn.RemoteAddr().String(),
})
c.clientsMux.Lock()
c.clients[conn] = true
c.clientsMux.Unlock()
go c.handleConnection(conn, ctx)
}
}
}
func (c *MaixCamChannel) handleConnection(conn net.Conn, ctx context.Context) {
logger.DebugC("maixcam", "Handling MaixCam connection")
defer func() {
conn.Close()
c.clientsMux.Lock()
delete(c.clients, conn)
c.clientsMux.Unlock()
logger.DebugC("maixcam", "Connection closed")
}()
decoder := json.NewDecoder(conn)
for {
select {
case <-ctx.Done():
return
default:
var msg MaixCamMessage
if err := decoder.Decode(&msg); err != nil {
if err.Error() != "EOF" {
logger.ErrorCF("maixcam", "Failed to decode message", map[string]interface{}{
"error": err.Error(),
})
}
return
}
c.processMessage(msg, conn)
}
}
}
func (c *MaixCamChannel) processMessage(msg MaixCamMessage, conn net.Conn) {
switch msg.Type {
case "person_detected":
c.handlePersonDetection(msg)
case "heartbeat":
logger.DebugC("maixcam", "Received heartbeat")
case "status":
c.handleStatusUpdate(msg)
default:
logger.WarnCF("maixcam", "Unknown message type", map[string]interface{}{
"type": msg.Type,
})
}
}
func (c *MaixCamChannel) handlePersonDetection(msg MaixCamMessage) {
logger.InfoCF("maixcam", "", map[string]interface{}{
"timestamp": msg.Timestamp,
"data": msg.Data,
})
senderID := "maixcam"
chatID := "default"
classInfo, ok := msg.Data["class_name"].(string)
if !ok {
classInfo = "person"
}
score, _ := msg.Data["score"].(float64)
x, _ := msg.Data["x"].(float64)
y, _ := msg.Data["y"].(float64)
w, _ := msg.Data["w"].(float64)
h, _ := msg.Data["h"].(float64)
content := fmt.Sprintf("📷 Person detected!\nClass: %s\nConfidence: %.2f%%\nPosition: (%.0f, %.0f)\nSize: %.0fx%.0f",
classInfo, score*100, x, y, w, h)
metadata := map[string]string{
"timestamp": fmt.Sprintf("%.0f", msg.Timestamp),
"class_id": fmt.Sprintf("%.0f", msg.Data["class_id"]),
"score": fmt.Sprintf("%.2f", score),
"x": fmt.Sprintf("%.0f", x),
"y": fmt.Sprintf("%.0f", y),
"w": fmt.Sprintf("%.0f", w),
"h": fmt.Sprintf("%.0f", h),
}
c.HandleMessage(senderID, chatID, content, []string{}, metadata)
}
func (c *MaixCamChannel) handleStatusUpdate(msg MaixCamMessage) {
logger.InfoCF("maixcam", "Status update from MaixCam", map[string]interface{}{
"status": msg.Data,
})
}
func (c *MaixCamChannel) Stop(ctx context.Context) error {
logger.InfoC("maixcam", "Stopping MaixCam channel")
c.setRunning(false)
if c.listener != nil {
c.listener.Close()
}
c.clientsMux.Lock()
defer c.clientsMux.Unlock()
for conn := range c.clients {
conn.Close()
}
c.clients = make(map[net.Conn]bool)
logger.InfoC("maixcam", "MaixCam channel stopped")
return nil
}
func (c *MaixCamChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("maixcam channel not running")
}
c.clientsMux.RLock()
defer c.clientsMux.RUnlock()
if len(c.clients) == 0 {
logger.WarnC("maixcam", "No MaixCam devices connected")
return fmt.Errorf("no connected MaixCam devices")
}
response := map[string]interface{}{
"type": "command",
"timestamp": float64(0),
"message": msg.Content,
"chat_id": msg.ChatID,
}
data, err := json.Marshal(response)
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
var sendErr error
for conn := range c.clients {
if _, err := conn.Write(data); err != nil {
logger.ErrorCF("maixcam", "Failed to send to client", map[string]interface{}{
"client": conn.RemoteAddr().String(),
"error": err.Error(),
})
sendErr = err
}
}
return sendErr
}

261
pkg/channels/manager.go Normal file
View File

@@ -0,0 +1,261 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package channels
import (
"context"
"fmt"
"sync"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
type Manager struct {
channels map[string]Channel
bus *bus.MessageBus
config *config.Config
dispatchTask *asyncTask
mu sync.RWMutex
}
type asyncTask struct {
cancel context.CancelFunc
}
func NewManager(cfg *config.Config, messageBus *bus.MessageBus) (*Manager, error) {
m := &Manager{
channels: make(map[string]Channel),
bus: messageBus,
config: cfg,
}
if err := m.initChannels(); err != nil {
return nil, err
}
return m, nil
}
func (m *Manager) initChannels() error {
logger.InfoC("channels", "Initializing channel manager")
if m.config.Channels.Telegram.Enabled && m.config.Channels.Telegram.Token != "" {
logger.DebugC("channels", "Attempting to initialize Telegram channel")
telegram, err := NewTelegramChannel(m.config.Channels.Telegram, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Telegram channel", map[string]interface{}{
"error": err.Error(),
})
} else {
m.channels["telegram"] = telegram
logger.InfoC("channels", "Telegram channel enabled successfully")
}
}
if m.config.Channels.WhatsApp.Enabled && m.config.Channels.WhatsApp.BridgeURL != "" {
logger.DebugC("channels", "Attempting to initialize WhatsApp channel")
whatsapp, err := NewWhatsAppChannel(m.config.Channels.WhatsApp, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize WhatsApp channel", map[string]interface{}{
"error": err.Error(),
})
} else {
m.channels["whatsapp"] = whatsapp
logger.InfoC("channels", "WhatsApp channel enabled successfully")
}
}
if m.config.Channels.Discord.Enabled && m.config.Channels.Discord.Token != "" {
logger.DebugC("channels", "Attempting to initialize Discord channel")
discord, err := NewDiscordChannel(m.config.Channels.Discord, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize Discord channel", map[string]interface{}{
"error": err.Error(),
})
} else {
m.channels["discord"] = discord
logger.InfoC("channels", "Discord channel enabled successfully")
}
}
if m.config.Channels.MaixCam.Enabled {
logger.DebugC("channels", "Attempting to initialize MaixCam channel")
maixcam, err := NewMaixCamChannel(m.config.Channels.MaixCam, m.bus)
if err != nil {
logger.ErrorCF("channels", "Failed to initialize MaixCam channel", map[string]interface{}{
"error": err.Error(),
})
} else {
m.channels["maixcam"] = maixcam
logger.InfoC("channels", "MaixCam channel enabled successfully")
}
}
logger.InfoCF("channels", "Channel initialization completed", map[string]interface{}{
"enabled_channels": len(m.channels),
})
return nil
}
func (m *Manager) StartAll(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
if len(m.channels) == 0 {
logger.WarnC("channels", "No channels enabled")
return nil
}
logger.InfoC("channels", "Starting all channels")
dispatchCtx, cancel := context.WithCancel(ctx)
m.dispatchTask = &asyncTask{cancel: cancel}
go m.dispatchOutbound(dispatchCtx)
for name, channel := range m.channels {
logger.InfoCF("channels", "Starting channel", map[string]interface{}{
"channel": name,
})
if err := channel.Start(ctx); err != nil {
logger.ErrorCF("channels", "Failed to start channel", map[string]interface{}{
"channel": name,
"error": err.Error(),
})
}
}
logger.InfoC("channels", "All channels started")
return nil
}
func (m *Manager) StopAll(ctx context.Context) error {
m.mu.Lock()
defer m.mu.Unlock()
logger.InfoC("channels", "Stopping all channels")
if m.dispatchTask != nil {
m.dispatchTask.cancel()
m.dispatchTask = nil
}
for name, channel := range m.channels {
logger.InfoCF("channels", "Stopping channel", map[string]interface{}{
"channel": name,
})
if err := channel.Stop(ctx); err != nil {
logger.ErrorCF("channels", "Error stopping channel", map[string]interface{}{
"channel": name,
"error": err.Error(),
})
}
}
logger.InfoC("channels", "All channels stopped")
return nil
}
func (m *Manager) dispatchOutbound(ctx context.Context) {
logger.InfoC("channels", "Outbound dispatcher started")
for {
select {
case <-ctx.Done():
logger.InfoC("channels", "Outbound dispatcher stopped")
return
default:
msg, ok := m.bus.SubscribeOutbound(ctx)
if !ok {
continue
}
m.mu.RLock()
channel, exists := m.channels[msg.Channel]
m.mu.RUnlock()
if !exists {
logger.WarnCF("channels", "Unknown channel for outbound message", map[string]interface{}{
"channel": msg.Channel,
})
continue
}
if err := channel.Send(ctx, msg); err != nil {
logger.ErrorCF("channels", "Error sending message to channel", map[string]interface{}{
"channel": msg.Channel,
"error": err.Error(),
})
}
}
}
}
func (m *Manager) GetChannel(name string) (Channel, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
channel, ok := m.channels[name]
return channel, ok
}
func (m *Manager) GetStatus() map[string]interface{} {
m.mu.RLock()
defer m.mu.RUnlock()
status := make(map[string]interface{})
for name, channel := range m.channels {
status[name] = map[string]interface{}{
"enabled": true,
"running": channel.IsRunning(),
}
}
return status
}
func (m *Manager) GetEnabledChannels() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, 0, len(m.channels))
for name := range m.channels {
names = append(names, name)
}
return names
}
func (m *Manager) RegisterChannel(name string, channel Channel) {
m.mu.Lock()
defer m.mu.Unlock()
m.channels[name] = channel
}
func (m *Manager) UnregisterChannel(name string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.channels, name)
}
func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, content string) error {
m.mu.RLock()
channel, exists := m.channels[channelName]
m.mu.RUnlock()
if !exists {
return fmt.Errorf("channel %s not found", channelName)
}
msg := bus.OutboundMessage{
Channel: channelName,
ChatID: chatID,
Content: content,
}
return channel.Send(ctx, msg)
}

394
pkg/channels/telegram.go Normal file
View File

@@ -0,0 +1,394 @@
package channels
import (
"context"
"fmt"
"log"
"regexp"
"strings"
"time"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/voice"
)
type TelegramChannel struct {
*BaseChannel
bot *tgbotapi.BotAPI
config config.TelegramConfig
chatIDs map[string]int64
updates tgbotapi.UpdatesChannel
transcriber *voice.GroqTranscriber
}
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
bot, err := tgbotapi.NewBotAPI(cfg.Token)
if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err)
}
base := NewBaseChannel("telegram", cfg, bus, cfg.AllowFrom)
return &TelegramChannel{
BaseChannel: base,
bot: bot,
config: cfg,
chatIDs: make(map[string]int64),
transcriber: nil,
}, nil
}
func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) {
c.transcriber = transcriber
}
func (c *TelegramChannel) Start(ctx context.Context) error {
log.Printf("Starting Telegram bot (polling mode)...")
u := tgbotapi.NewUpdate(0)
u.Timeout = 30
updates := c.bot.GetUpdatesChan(u)
c.updates = updates
c.setRunning(true)
botInfo, err := c.bot.GetMe()
if err != nil {
return fmt.Errorf("failed to get bot info: %w", err)
}
log.Printf("Telegram bot @%s connected", botInfo.UserName)
go func() {
for {
select {
case <-ctx.Done():
return
case update, ok := <-updates:
if !ok {
log.Printf("Updates channel closed, reconnecting...")
return
}
if update.Message != nil {
c.handleMessage(update)
}
}
}
}()
return nil
}
func (c *TelegramChannel) Stop(ctx context.Context) error {
log.Println("Stopping Telegram bot...")
c.setRunning(false)
if c.updates != nil {
c.bot.StopReceivingUpdates()
c.updates = nil
}
return nil
}
func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
if !c.IsRunning() {
return fmt.Errorf("telegram bot not running")
}
chatID, err := parseChatID(msg.ChatID)
if err != nil {
return fmt.Errorf("invalid chat ID: %w", err)
}
htmlContent := markdownToTelegramHTML(msg.Content)
tgMsg := tgbotapi.NewMessage(chatID, htmlContent)
tgMsg.ParseMode = tgbotapi.ModeHTML
if _, err := c.bot.Send(tgMsg); err != nil {
log.Printf("HTML parse failed, falling back to plain text: %v", err)
tgMsg = tgbotapi.NewMessage(chatID, msg.Content)
tgMsg.ParseMode = ""
_, err = c.bot.Send(tgMsg)
return err
}
return nil
}
func (c *TelegramChannel) handleMessage(update tgbotapi.Update) {
message := update.Message
if message == nil {
return
}
user := message.From
if user == nil {
return
}
senderID := fmt.Sprintf("%d", user.ID)
if user.UserName != "" {
senderID = fmt.Sprintf("%d|%s", user.ID, user.UserName)
}
chatID := message.Chat.ID
c.chatIDs[senderID] = chatID
content := ""
mediaPaths := []string{}
if message.Text != "" {
content += message.Text
}
if message.Caption != "" {
if content != "" {
content += "\n"
}
content += message.Caption
}
if message.Photo != nil && len(message.Photo) > 0 {
photo := message.Photo[len(message.Photo)-1]
photoPath := c.downloadPhoto(photo.FileID)
if photoPath != "" {
mediaPaths = append(mediaPaths, photoPath)
if content != "" {
content += "\n"
}
content += fmt.Sprintf("[image: %s]", photoPath)
}
}
if message.Voice != nil {
voicePath := c.downloadFile(message.Voice.FileID, ".ogg")
if voicePath != "" {
mediaPaths = append(mediaPaths, voicePath)
transcribedText := ""
if c.transcriber != nil && c.transcriber.IsAvailable() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
result, err := c.transcriber.Transcribe(ctx, voicePath)
if err != nil {
log.Printf("Voice transcription failed: %v", err)
transcribedText = fmt.Sprintf("[voice: %s (transcription failed)]", voicePath)
} else {
transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text)
log.Printf("Voice transcribed successfully: %s", result.Text)
}
} else {
transcribedText = fmt.Sprintf("[voice: %s]", voicePath)
}
if content != "" {
content += "\n"
}
content += transcribedText
}
}
if message.Audio != nil {
audioPath := c.downloadFile(message.Audio.FileID, ".mp3")
if audioPath != "" {
mediaPaths = append(mediaPaths, audioPath)
if content != "" {
content += "\n"
}
content += fmt.Sprintf("[audio: %s]", audioPath)
}
}
if message.Document != nil {
docPath := c.downloadFile(message.Document.FileID, "")
if docPath != "" {
mediaPaths = append(mediaPaths, docPath)
if content != "" {
content += "\n"
}
content += fmt.Sprintf("[file: %s]", docPath)
}
}
if content == "" {
content = "[empty message]"
}
log.Printf("Telegram message from %s: %s...", senderID, truncateString(content, 50))
metadata := map[string]string{
"message_id": fmt.Sprintf("%d", message.MessageID),
"user_id": fmt.Sprintf("%d", user.ID),
"username": user.UserName,
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
}
c.HandleMessage(senderID, fmt.Sprintf("%d", chatID), content, mediaPaths, metadata)
}
func (c *TelegramChannel) downloadPhoto(fileID string) string {
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil {
log.Printf("Failed to get photo file: %v", err)
return ""
}
return c.downloadFileWithInfo(&file, ".jpg")
}
func (c *TelegramChannel) downloadFileWithInfo(file *tgbotapi.File, ext string) string {
if file.FilePath == "" {
return ""
}
url := file.Link(c.bot.Token)
log.Printf("File URL: %s", url)
mediaDir := "/tmp/picoclaw_media"
return fmt.Sprintf("%s/%s%s", mediaDir, file.FilePath[:min(16, len(file.FilePath))], ext)
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func (c *TelegramChannel) downloadFile(fileID, ext string) string {
file, err := c.bot.GetFile(tgbotapi.FileConfig{FileID: fileID})
if err != nil {
log.Printf("Failed to get file: %v", err)
return ""
}
if file.FilePath == "" {
return ""
}
url := file.Link(c.bot.Token)
log.Printf("File URL: %s", url)
mediaDir := "/tmp/picoclaw_media"
return fmt.Sprintf("%s/%s%s", mediaDir, fileID[:16], ext)
}
func parseChatID(chatIDStr string) (int64, error) {
var id int64
_, err := fmt.Sscanf(chatIDStr, "%d", &id)
return id, err
}
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}
func markdownToTelegramHTML(text string) string {
if text == "" {
return ""
}
codeBlocks := extractCodeBlocks(text)
text = codeBlocks.text
inlineCodes := extractInlineCodes(text)
text = inlineCodes.text
text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1")
text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1")
text = escapeHTML(text)
text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `<a href="$2">$1</a>`)
text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "<b>$1</b>")
text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "<b>$1</b>")
reItalic := regexp.MustCompile(`_([^_]+)_`)
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
match := reItalic.FindStringSubmatch(s)
if len(match) < 2 {
return s
}
return "<i>" + match[1] + "</i>"
})
text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "<s>$1</s>")
text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ")
for i, code := range inlineCodes.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped))
}
for i, code := range codeBlocks.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("<pre><code>%s</code></pre>", escaped))
}
return text
}
type codeBlockMatch struct {
text string
codes []string
}
func extractCodeBlocks(text string) codeBlockMatch {
re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
matches := re.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
codes = append(codes, match[1])
}
text = re.ReplaceAllStringFunc(text, func(m string) string {
return fmt.Sprintf("\x00CB%d\x00", len(codes)-1)
})
return codeBlockMatch{text: text, codes: codes}
}
type inlineCodeMatch struct {
text string
codes []string
}
func extractInlineCodes(text string) inlineCodeMatch {
re := regexp.MustCompile("`([^`]+)`")
matches := re.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
codes = append(codes, match[1])
}
text = re.ReplaceAllStringFunc(text, func(m string) string {
return fmt.Sprintf("\x00IC%d\x00", len(codes)-1)
})
return inlineCodeMatch{text: text, codes: codes}
}
func escapeHTML(text string) string {
text = strings.ReplaceAll(text, "&", "&amp;")
text = strings.ReplaceAll(text, "<", "&lt;")
text = strings.ReplaceAll(text, ">", "&gt;")
return text
}

183
pkg/channels/whatsapp.go Normal file
View File

@@ -0,0 +1,183 @@
package channels
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/config"
)
type WhatsAppChannel struct {
*BaseChannel
conn *websocket.Conn
config config.WhatsAppConfig
url string
mu sync.Mutex
connected bool
}
func NewWhatsAppChannel(cfg config.WhatsAppConfig, bus *bus.MessageBus) (*WhatsAppChannel, error) {
base := NewBaseChannel("whatsapp", cfg, bus, cfg.AllowFrom)
return &WhatsAppChannel{
BaseChannel: base,
config: cfg,
url: cfg.BridgeURL,
connected: false,
}, nil
}
func (c *WhatsAppChannel) Start(ctx context.Context) error {
log.Printf("Starting WhatsApp channel connecting to %s...", c.url)
dialer := websocket.DefaultDialer
dialer.HandshakeTimeout = 10 * time.Second
conn, _, err := dialer.Dial(c.url, nil)
if err != nil {
return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err)
}
c.mu.Lock()
c.conn = conn
c.connected = true
c.mu.Unlock()
c.setRunning(true)
log.Println("WhatsApp channel connected")
go c.listen(ctx)
return nil
}
func (c *WhatsAppChannel) Stop(ctx context.Context) error {
log.Println("Stopping WhatsApp channel...")
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
if err := c.conn.Close(); err != nil {
log.Printf("Error closing WhatsApp connection: %v", err)
}
c.conn = nil
}
c.connected = false
c.setRunning(false)
return nil
}
func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("whatsapp connection not established")
}
payload := map[string]interface{}{
"type": "message",
"to": msg.ChatID,
"content": msg.Content,
}
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal message: %w", err)
}
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
return fmt.Errorf("failed to send message: %w", err)
}
return nil
}
func (c *WhatsAppChannel) listen(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
time.Sleep(1 * time.Second)
continue
}
_, message, err := conn.ReadMessage()
if err != nil {
log.Printf("WhatsApp read error: %v", err)
time.Sleep(2 * time.Second)
continue
}
var msg map[string]interface{}
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("Failed to unmarshal WhatsApp message: %v", err)
continue
}
msgType, ok := msg["type"].(string)
if !ok {
continue
}
if msgType == "message" {
c.handleIncomingMessage(msg)
}
}
}
}
func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]interface{}) {
senderID, ok := msg["from"].(string)
if !ok {
return
}
chatID, ok := msg["chat"].(string)
if !ok {
chatID = senderID
}
content, ok := msg["content"].(string)
if !ok {
content = ""
}
var mediaPaths []string
if mediaData, ok := msg["media"].([]interface{}); ok {
mediaPaths = make([]string, 0, len(mediaData))
for _, m := range mediaData {
if path, ok := m.(string); ok {
mediaPaths = append(mediaPaths, path)
}
}
}
metadata := make(map[string]string)
if messageID, ok := msg["id"].(string); ok {
metadata["message_id"] = messageID
}
if userName, ok := msg["from_name"].(string); ok {
metadata["user_name"] = userName
}
log.Printf("WhatsApp message from %s: %s...", senderID, truncateString(content, 50))
c.HandleMessage(senderID, chatID, content, mediaPaths, metadata)
}

276
pkg/config/config.go Normal file
View File

@@ -0,0 +1,276 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"github.com/caarlos0/env/v11"
)
type Config struct {
Agents AgentsConfig `json:"agents"`
Channels ChannelsConfig `json:"channels"`
Providers ProvidersConfig `json:"providers"`
Gateway GatewayConfig `json:"gateway"`
Tools ToolsConfig `json:"tools"`
mu sync.RWMutex
}
type AgentsConfig struct {
Defaults AgentDefaults `json:"defaults"`
}
type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
}
type ChannelsConfig struct {
WhatsApp WhatsAppConfig `json:"whatsapp"`
Telegram TelegramConfig `json:"telegram"`
Feishu FeishuConfig `json:"feishu"`
Discord DiscordConfig `json:"discord"`
MaixCam MaixCamConfig `json:"maixcam"`
}
type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
}
type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
}
type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
}
type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
}
type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
}
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"`
}
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"`
}
type GatewayConfig struct {
Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"`
Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"`
}
type WebSearchConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_SEARCH_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SEARCH_MAX_RESULTS"`
}
type WebToolsConfig struct {
Search WebSearchConfig `json:"search"`
}
type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
}
func DefaultConfig() *Config {
return &Config{
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: "~/.picoclaw/workspace",
Model: "glm-4.7",
MaxTokens: 8192,
Temperature: 0.7,
MaxToolIterations: 20,
},
},
Channels: ChannelsConfig{
WhatsApp: WhatsAppConfig{
Enabled: false,
BridgeURL: "ws://localhost:3001",
AllowFrom: []string{},
},
Telegram: TelegramConfig{
Enabled: false,
Token: "",
AllowFrom: []string{},
},
Feishu: FeishuConfig{
Enabled: false,
AppID: "",
AppSecret: "",
EncryptKey: "",
VerificationToken: "",
AllowFrom: []string{},
},
Discord: DiscordConfig{
Enabled: false,
Token: "",
AllowFrom: []string{},
},
MaixCam: MaixCamConfig{
Enabled: false,
Host: "0.0.0.0",
Port: 18790,
AllowFrom: []string{},
},
},
Providers: ProvidersConfig{
Anthropic: ProviderConfig{},
OpenAI: ProviderConfig{},
OpenRouter: ProviderConfig{},
Groq: ProviderConfig{},
Zhipu: ProviderConfig{},
VLLM: ProviderConfig{},
Gemini: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
Port: 18790,
},
Tools: ToolsConfig{
Web: WebToolsConfig{
Search: WebSearchConfig{
APIKey: "",
MaxResults: 5,
},
},
},
}
}
func LoadConfig(path string) (*Config, error) {
cfg := DefaultConfig()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return nil, err
}
if err := json.Unmarshal(data, cfg); err != nil {
return nil, err
}
if err := env.Parse(cfg); err != nil {
return nil, err
}
return cfg, nil
}
func SaveConfig(path string, cfg *Config) error {
cfg.mu.RLock()
defer cfg.mu.RUnlock()
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}
func (c *Config) WorkspacePath() string {
c.mu.RLock()
defer c.mu.RUnlock()
return expandHome(c.Agents.Defaults.Workspace)
}
func (c *Config) GetAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.Providers.OpenRouter.APIKey != "" {
return c.Providers.OpenRouter.APIKey
}
if c.Providers.Anthropic.APIKey != "" {
return c.Providers.Anthropic.APIKey
}
if c.Providers.OpenAI.APIKey != "" {
return c.Providers.OpenAI.APIKey
}
if c.Providers.Gemini.APIKey != "" {
return c.Providers.Gemini.APIKey
}
if c.Providers.Zhipu.APIKey != "" {
return c.Providers.Zhipu.APIKey
}
if c.Providers.Groq.APIKey != "" {
return c.Providers.Groq.APIKey
}
if c.Providers.VLLM.APIKey != "" {
return c.Providers.VLLM.APIKey
}
return ""
}
func (c *Config) GetAPIBase() string {
c.mu.RLock()
defer c.mu.RUnlock()
if c.Providers.OpenRouter.APIKey != "" {
if c.Providers.OpenRouter.APIBase != "" {
return c.Providers.OpenRouter.APIBase
}
return "https://openrouter.ai/api/v1"
}
if c.Providers.Zhipu.APIKey != "" {
return c.Providers.Zhipu.APIBase
}
if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" {
return c.Providers.VLLM.APIBase
}
return ""
}
func expandHome(path string) string {
if path == "" {
return path
}
if path[0] == '~' {
home, _ := os.UserHomeDir()
if len(path) > 1 && path[1] == '/' {
return home + path[1:]
}
return home
}
return path
}

381
pkg/cron/service.go Normal file
View File

@@ -0,0 +1,381 @@
package cron
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type CronSchedule struct {
Kind string `json:"kind"`
AtMS *int64 `json:"atMs,omitempty"`
EveryMS *int64 `json:"everyMs,omitempty"`
Expr string `json:"expr,omitempty"`
TZ string `json:"tz,omitempty"`
}
type CronPayload struct {
Kind string `json:"kind"`
Message string `json:"message"`
Deliver bool `json:"deliver"`
Channel string `json:"channel,omitempty"`
To string `json:"to,omitempty"`
}
type CronJobState struct {
NextRunAtMS *int64 `json:"nextRunAtMs,omitempty"`
LastRunAtMS *int64 `json:"lastRunAtMs,omitempty"`
LastStatus string `json:"lastStatus,omitempty"`
LastError string `json:"lastError,omitempty"`
}
type CronJob struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
Schedule CronSchedule `json:"schedule"`
Payload CronPayload `json:"payload"`
State CronJobState `json:"state"`
CreatedAtMS int64 `json:"createdAtMs"`
UpdatedAtMS int64 `json:"updatedAtMs"`
DeleteAfterRun bool `json:"deleteAfterRun"`
}
type CronStore struct {
Version int `json:"version"`
Jobs []CronJob `json:"jobs"`
}
type JobHandler func(job *CronJob) (string, error)
type CronService struct {
storePath string
store *CronStore
onJob JobHandler
mu sync.RWMutex
running bool
stopChan chan struct{}
}
func NewCronService(storePath string, onJob JobHandler) *CronService {
cs := &CronService{
storePath: storePath,
onJob: onJob,
stopChan: make(chan struct{}),
}
cs.loadStore()
return cs
}
func (cs *CronService) Start() error {
cs.mu.Lock()
defer cs.mu.Unlock()
if cs.running {
return nil
}
if err := cs.loadStore(); err != nil {
return fmt.Errorf("failed to load store: %w", err)
}
cs.recomputeNextRuns()
if err := cs.saveStore(); err != nil {
return fmt.Errorf("failed to save store: %w", err)
}
cs.running = true
go cs.runLoop()
return nil
}
func (cs *CronService) Stop() {
cs.mu.Lock()
defer cs.mu.Unlock()
if !cs.running {
return
}
cs.running = false
close(cs.stopChan)
}
func (cs *CronService) runLoop() {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
select {
case <-cs.stopChan:
return
case <-ticker.C:
cs.checkJobs()
}
}
}
func (cs *CronService) checkJobs() {
cs.mu.RLock()
if !cs.running {
cs.mu.RUnlock()
return
}
now := time.Now().UnixMilli()
var dueJobs []*CronJob
for i := range cs.store.Jobs {
job := &cs.store.Jobs[i]
if job.Enabled && job.State.NextRunAtMS != nil && *job.State.NextRunAtMS <= now {
dueJobs = append(dueJobs, job)
}
}
cs.mu.RUnlock()
for _, job := range dueJobs {
cs.executeJob(job)
}
cs.mu.Lock()
defer cs.mu.Unlock()
cs.saveStore()
}
func (cs *CronService) executeJob(job *CronJob) {
startTime := time.Now().UnixMilli()
var err error
if cs.onJob != nil {
_, err = cs.onJob(job)
}
cs.mu.Lock()
defer cs.mu.Unlock()
job.State.LastRunAtMS = &startTime
job.UpdatedAtMS = time.Now().UnixMilli()
if err != nil {
job.State.LastStatus = "error"
job.State.LastError = err.Error()
} else {
job.State.LastStatus = "ok"
job.State.LastError = ""
}
if job.Schedule.Kind == "at" {
if job.DeleteAfterRun {
cs.removeJobUnsafe(job.ID)
} else {
job.Enabled = false
job.State.NextRunAtMS = nil
}
} else {
nextRun := cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
job.State.NextRunAtMS = nextRun
}
}
func (cs *CronService) computeNextRun(schedule *CronSchedule, nowMS int64) *int64 {
if schedule.Kind == "at" {
if schedule.AtMS != nil && *schedule.AtMS > nowMS {
return schedule.AtMS
}
return nil
}
if schedule.Kind == "every" {
if schedule.EveryMS == nil || *schedule.EveryMS <= 0 {
return nil
}
next := nowMS + *schedule.EveryMS
return &next
}
return nil
}
func (cs *CronService) recomputeNextRuns() {
now := time.Now().UnixMilli()
for i := range cs.store.Jobs {
job := &cs.store.Jobs[i]
if job.Enabled {
job.State.NextRunAtMS = cs.computeNextRun(&job.Schedule, now)
}
}
}
func (cs *CronService) getNextWakeMS() *int64 {
var nextWake *int64
for _, job := range cs.store.Jobs {
if job.Enabled && job.State.NextRunAtMS != nil {
if nextWake == nil || *job.State.NextRunAtMS < *nextWake {
nextWake = job.State.NextRunAtMS
}
}
}
return nextWake
}
func (cs *CronService) Load() error {
return cs.loadStore()
}
func (cs *CronService) loadStore() error {
cs.store = &CronStore{
Version: 1,
Jobs: []CronJob{},
}
data, err := os.ReadFile(cs.storePath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
return json.Unmarshal(data, cs.store)
}
func (cs *CronService) saveStore() error {
dir := filepath.Dir(cs.storePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
data, err := json.MarshalIndent(cs.store, "", " ")
if err != nil {
return err
}
return os.WriteFile(cs.storePath, data, 0644)
}
func (cs *CronService) AddJob(name string, schedule CronSchedule, message string, deliver bool, channel, to string) (*CronJob, error) {
cs.mu.Lock()
defer cs.mu.Unlock()
now := time.Now().UnixMilli()
job := CronJob{
ID: generateID(),
Name: name,
Enabled: true,
Schedule: schedule,
Payload: CronPayload{
Kind: "agent_turn",
Message: message,
Deliver: deliver,
Channel: channel,
To: to,
},
State: CronJobState{
NextRunAtMS: cs.computeNextRun(&schedule, now),
},
CreatedAtMS: now,
UpdatedAtMS: now,
DeleteAfterRun: false,
}
cs.store.Jobs = append(cs.store.Jobs, job)
if err := cs.saveStore(); err != nil {
return nil, err
}
return &job, nil
}
func (cs *CronService) RemoveJob(jobID string) bool {
cs.mu.Lock()
defer cs.mu.Unlock()
return cs.removeJobUnsafe(jobID)
}
func (cs *CronService) removeJobUnsafe(jobID string) bool {
before := len(cs.store.Jobs)
var jobs []CronJob
for _, job := range cs.store.Jobs {
if job.ID != jobID {
jobs = append(jobs, job)
}
}
cs.store.Jobs = jobs
removed := len(cs.store.Jobs) < before
if removed {
cs.saveStore()
}
return removed
}
func (cs *CronService) EnableJob(jobID string, enabled bool) *CronJob {
cs.mu.Lock()
defer cs.mu.Unlock()
for i := range cs.store.Jobs {
job := &cs.store.Jobs[i]
if job.ID == jobID {
job.Enabled = enabled
job.UpdatedAtMS = time.Now().UnixMilli()
if enabled {
job.State.NextRunAtMS = cs.computeNextRun(&job.Schedule, time.Now().UnixMilli())
} else {
job.State.NextRunAtMS = nil
}
cs.saveStore()
return job
}
}
return nil
}
func (cs *CronService) ListJobs(includeDisabled bool) []CronJob {
cs.mu.RLock()
defer cs.mu.RUnlock()
if includeDisabled {
return cs.store.Jobs
}
var enabled []CronJob
for _, job := range cs.store.Jobs {
if job.Enabled {
enabled = append(enabled, job)
}
}
return enabled
}
func (cs *CronService) Status() map[string]interface{} {
cs.mu.RLock()
defer cs.mu.RUnlock()
var enabledCount int
for _, job := range cs.store.Jobs {
if job.Enabled {
enabledCount++
}
}
return map[string]interface{}{
"enabled": cs.running,
"jobs": len(cs.store.Jobs),
"nextWakeAtMS": cs.getNextWakeMS(),
}
}
func generateID() string {
return fmt.Sprintf("%d", time.Now().UnixNano())
}

134
pkg/heartbeat/service.go Normal file
View File

@@ -0,0 +1,134 @@
package heartbeat
import (
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
type HeartbeatService struct {
workspace string
onHeartbeat func(string) (string, error)
interval time.Duration
enabled bool
mu sync.RWMutex
stopChan chan struct{}
}
func NewHeartbeatService(workspace string, onHeartbeat func(string) (string, error), intervalS int, enabled bool) *HeartbeatService {
return &HeartbeatService{
workspace: workspace,
onHeartbeat: onHeartbeat,
interval: time.Duration(intervalS) * time.Second,
enabled: enabled,
stopChan: make(chan struct{}),
}
}
func (hs *HeartbeatService) Start() error {
hs.mu.Lock()
defer hs.mu.Unlock()
if hs.running() {
return nil
}
if !hs.enabled {
return fmt.Errorf("heartbeat service is disabled")
}
go hs.runLoop()
return nil
}
func (hs *HeartbeatService) Stop() {
hs.mu.Lock()
defer hs.mu.Unlock()
if !hs.running() {
return
}
close(hs.stopChan)
}
func (hs *HeartbeatService) running() bool {
select {
case <-hs.stopChan:
return false
default:
return true
}
}
func (hs *HeartbeatService) runLoop() {
ticker := time.NewTicker(hs.interval)
defer ticker.Stop()
for {
select {
case <-hs.stopChan:
return
case <-ticker.C:
hs.checkHeartbeat()
}
}
}
func (hs *HeartbeatService) checkHeartbeat() {
hs.mu.RLock()
if !hs.enabled || !hs.running() {
hs.mu.RUnlock()
return
}
hs.mu.RUnlock()
prompt := hs.buildPrompt()
if hs.onHeartbeat != nil {
_, err := hs.onHeartbeat(prompt)
if err != nil {
hs.log(fmt.Sprintf("Heartbeat error: %v", err))
}
}
}
func (hs *HeartbeatService) buildPrompt() string {
notesDir := filepath.Join(hs.workspace, "memory")
notesFile := filepath.Join(notesDir, "HEARTBEAT.md")
var notes string
if data, err := os.ReadFile(notesFile); err == nil {
notes = string(data)
}
now := time.Now().Format("2006-01-02 15:04")
prompt := fmt.Sprintf(`# Heartbeat Check
Current time: %s
Check if there are any tasks I should be aware of or actions I should take.
Review the memory file for any important updates or changes.
Be proactive in identifying potential issues or improvements.
%s
`, now, notes)
return prompt
}
func (hs *HeartbeatService) log(message string) {
logFile := filepath.Join(hs.workspace, "memory", "heartbeat.log")
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return
}
defer f.Close()
timestamp := time.Now().Format("2006-01-02 15:04:05")
f.WriteString(fmt.Sprintf("[%s] %s\n", timestamp, message))
}

239
pkg/logger/logger.go Normal file
View File

@@ -0,0 +1,239 @@
package logger
import (
"encoding/json"
"fmt"
"log"
"os"
"runtime"
"strings"
"sync"
"time"
)
type LogLevel int
const (
DEBUG LogLevel = iota
INFO
WARN
ERROR
FATAL
)
var (
logLevelNames = map[LogLevel]string{
DEBUG: "DEBUG",
INFO: "INFO",
WARN: "WARN",
ERROR: "ERROR",
FATAL: "FATAL",
}
currentLevel = INFO
logger *Logger
once sync.Once
mu sync.RWMutex
)
type Logger struct {
file *os.File
}
type LogEntry struct {
Level string `json:"level"`
Timestamp string `json:"timestamp"`
Component string `json:"component,omitempty"`
Message string `json:"message"`
Fields map[string]interface{} `json:"fields,omitempty"`
Caller string `json:"caller,omitempty"`
}
func init() {
once.Do(func() {
logger = &Logger{}
})
}
func SetLevel(level LogLevel) {
mu.Lock()
defer mu.Unlock()
currentLevel = level
}
func GetLevel() LogLevel {
mu.RLock()
defer mu.RUnlock()
return currentLevel
}
func EnableFileLogging(filePath string) error {
mu.Lock()
defer mu.Unlock()
file, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
if logger.file != nil {
logger.file.Close()
}
logger.file = file
log.Println("File logging enabled:", filePath)
return nil
}
func DisableFileLogging() {
mu.Lock()
defer mu.Unlock()
if logger.file != nil {
logger.file.Close()
logger.file = nil
log.Println("File logging disabled")
}
}
func logMessage(level LogLevel, component string, message string, fields map[string]interface{}) {
if level < currentLevel {
return
}
entry := LogEntry{
Level: logLevelNames[level],
Timestamp: time.Now().UTC().Format(time.RFC3339),
Component: component,
Message: message,
Fields: fields,
}
if pc, file, line, ok := runtime.Caller(2); ok {
fn := runtime.FuncForPC(pc)
if fn != nil {
entry.Caller = fmt.Sprintf("%s:%d (%s)", file, line, fn.Name())
}
}
if logger.file != nil {
jsonData, err := json.Marshal(entry)
if err == nil {
logger.file.WriteString(string(jsonData) + "\n")
}
}
var fieldStr string
if len(fields) > 0 {
fieldStr = " " + formatFields(fields)
}
logLine := fmt.Sprintf("[%s] [%s]%s %s%s",
entry.Timestamp,
logLevelNames[level],
formatComponent(component),
message,
fieldStr,
)
log.Println(logLine)
if level == FATAL {
os.Exit(1)
}
}
func formatComponent(component string) string {
if component == "" {
return ""
}
return fmt.Sprintf(" %s:", component)
}
func formatFields(fields map[string]interface{}) string {
var parts []string
for k, v := range fields {
parts = append(parts, fmt.Sprintf("%s=%v", k, v))
}
return fmt.Sprintf("{%s}", strings.Join(parts, ", "))
}
func Debug(message string) {
logMessage(DEBUG, "", message, nil)
}
func DebugC(component string, message string) {
logMessage(DEBUG, component, message, nil)
}
func DebugF(message string, fields map[string]interface{}) {
logMessage(DEBUG, "", message, fields)
}
func DebugCF(component string, message string, fields map[string]interface{}) {
logMessage(DEBUG, component, message, fields)
}
func Info(message string) {
logMessage(INFO, "", message, nil)
}
func InfoC(component string, message string) {
logMessage(INFO, component, message, nil)
}
func InfoF(message string, fields map[string]interface{}) {
logMessage(INFO, "", message, fields)
}
func InfoCF(component string, message string, fields map[string]interface{}) {
logMessage(INFO, component, message, fields)
}
func Warn(message string) {
logMessage(WARN, "", message, nil)
}
func WarnC(component string, message string) {
logMessage(WARN, component, message, nil)
}
func WarnF(message string, fields map[string]interface{}) {
logMessage(WARN, "", message, fields)
}
func WarnCF(component string, message string, fields map[string]interface{}) {
logMessage(WARN, component, message, fields)
}
func Error(message string) {
logMessage(ERROR, "", message, nil)
}
func ErrorC(component string, message string) {
logMessage(ERROR, component, message, nil)
}
func ErrorF(message string, fields map[string]interface{}) {
logMessage(ERROR, "", message, fields)
}
func ErrorCF(component string, message string, fields map[string]interface{}) {
logMessage(ERROR, component, message, fields)
}
func Fatal(message string) {
logMessage(FATAL, "", message, nil)
}
func FatalC(component string, message string) {
logMessage(FATAL, component, message, nil)
}
func FatalF(message string, fields map[string]interface{}) {
logMessage(FATAL, "", message, fields)
}
func FatalCF(component string, message string, fields map[string]interface{}) {
logMessage(FATAL, component, message, fields)
}

139
pkg/logger/logger_test.go Normal file
View File

@@ -0,0 +1,139 @@
package logger
import (
"testing"
)
func TestLogLevelFiltering(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(WARN)
tests := []struct {
name string
level LogLevel
shouldLog bool
}{
{"DEBUG message", DEBUG, false},
{"INFO message", INFO, false},
{"WARN message", WARN, true},
{"ERROR message", ERROR, true},
{"FATAL message", FATAL, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
switch tt.level {
case DEBUG:
Debug(tt.name)
case INFO:
Info(tt.name)
case WARN:
Warn(tt.name)
case ERROR:
Error(tt.name)
case FATAL:
if tt.shouldLog {
t.Logf("FATAL test skipped to prevent program exit")
}
}
})
}
SetLevel(INFO)
}
func TestLoggerWithComponent(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
tests := []struct {
name string
component string
message string
fields map[string]interface{}
}{
{"Simple message", "test", "Hello, world!", nil},
{"Message with component", "discord", "Discord message", nil},
{"Message with fields", "telegram", "Telegram message", map[string]interface{}{
"user_id": "12345",
"count": 42,
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
switch {
case tt.fields == nil && tt.component != "":
InfoC(tt.component, tt.message)
case tt.fields != nil:
InfoF(tt.message, tt.fields)
default:
Info(tt.message)
}
})
}
SetLevel(INFO)
}
func TestLogLevels(t *testing.T) {
tests := []struct {
name string
level LogLevel
want string
}{
{"DEBUG level", DEBUG, "DEBUG"},
{"INFO level", INFO, "INFO"},
{"WARN level", WARN, "WARN"},
{"ERROR level", ERROR, "ERROR"},
{"FATAL level", FATAL, "FATAL"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if logLevelNames[tt.level] != tt.want {
t.Errorf("logLevelNames[%d] = %s, want %s", tt.level, logLevelNames[tt.level], tt.want)
}
})
}
}
func TestSetGetLevel(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
tests := []LogLevel{DEBUG, INFO, WARN, ERROR, FATAL}
for _, level := range tests {
SetLevel(level)
if GetLevel() != level {
t.Errorf("SetLevel(%v) -> GetLevel() = %v, want %v", level, GetLevel(), level)
}
}
}
func TestLoggerHelperFunctions(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(INFO)
Debug("This should not log")
Info("This should log")
Warn("This should log")
Error("This should log")
InfoC("test", "Component message")
InfoF("Fields message", map[string]interface{}{"key": "value"})
WarnC("test", "Warning with component")
ErrorF("Error with fields", map[string]interface{}{"error": "test"})
SetLevel(DEBUG)
DebugC("test", "Debug with component")
WarnF("Warning with fields", map[string]interface{}{"key": "value"})
}

View File

@@ -0,0 +1,245 @@
// PicoClaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 PicoClaw contributors
package providers
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/sipeed/picoclaw/pkg/config"
)
type HTTPProvider struct {
apiKey string
apiBase string
httpClient *http.Client
}
func NewHTTPProvider(apiKey, apiBase string) *HTTPProvider {
return &HTTPProvider{
apiKey: apiKey,
apiBase: apiBase,
httpClient: &http.Client{
Timeout: 0,
},
}
}
func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) {
if p.apiBase == "" {
return nil, fmt.Errorf("API base not configured")
}
requestBody := map[string]interface{}{
"model": model,
"messages": messages,
}
if len(tools) > 0 {
requestBody["tools"] = tools
requestBody["tool_choice"] = "auto"
}
if maxTokens, ok := options["max_tokens"].(int); ok {
requestBody["max_tokens"] = maxTokens
}
if temperature, ok := options["temperature"].(float64); ok {
requestBody["temperature"] = temperature
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if p.apiKey != "" {
authHeader := "Bearer " + p.apiKey
req.Header.Set("Authorization", authHeader)
}
resp, err := p.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error: %s", string(body))
}
return p.parseResponse(body)
}
func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) {
var apiResponse struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Type string `json:"type"`
Function *struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage *UsageInfo `json:"usage"`
}
if err := json.Unmarshal(body, &apiResponse); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
if len(apiResponse.Choices) == 0 {
return &LLMResponse{
Content: "",
FinishReason: "stop",
}, nil
}
choice := apiResponse.Choices[0]
toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls))
for _, tc := range choice.Message.ToolCalls {
arguments := make(map[string]interface{})
name := ""
// Handle OpenAI format with nested function object
if tc.Type == "function" && tc.Function != nil {
name = tc.Function.Name
if tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
arguments["raw"] = tc.Function.Arguments
}
}
} else if tc.Function != nil {
// Legacy format without type field
name = tc.Function.Name
if tc.Function.Arguments != "" {
if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil {
arguments["raw"] = tc.Function.Arguments
}
}
}
toolCalls = append(toolCalls, ToolCall{
ID: tc.ID,
Name: name,
Arguments: arguments,
})
}
return &LLMResponse{
Content: choice.Message.Content,
ToolCalls: toolCalls,
FinishReason: choice.FinishReason,
Usage: apiResponse.Usage,
}, nil
}
func (p *HTTPProvider) GetDefaultModel() string {
return ""
}
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
model := cfg.Agents.Defaults.Model
var apiKey, apiBase string
lowerModel := strings.ToLower(model)
switch {
case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
apiKey = cfg.Providers.OpenRouter.APIKey
if cfg.Providers.OpenRouter.APIBase != "" {
apiBase = cfg.Providers.OpenRouter.APIBase
} else {
apiBase = "https://openrouter.ai/api/v1"
}
case strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/"):
apiKey = cfg.Providers.Anthropic.APIKey
apiBase = cfg.Providers.Anthropic.APIBase
if apiBase == "" {
apiBase = "https://api.anthropic.com/v1"
}
case strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/"):
apiKey = cfg.Providers.OpenAI.APIKey
apiBase = cfg.Providers.OpenAI.APIBase
if apiBase == "" {
apiBase = "https://api.openai.com/v1"
}
case strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/"):
apiKey = cfg.Providers.Gemini.APIKey
apiBase = cfg.Providers.Gemini.APIBase
if apiBase == "" {
apiBase = "https://generativelanguage.googleapis.com/v1beta"
}
case strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai"):
apiKey = cfg.Providers.Zhipu.APIKey
apiBase = cfg.Providers.Zhipu.APIBase
if apiBase == "" {
apiBase = "https://open.bigmodel.cn/api/paas/v4"
}
case strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/"):
apiKey = cfg.Providers.Groq.APIKey
apiBase = cfg.Providers.Groq.APIBase
if apiBase == "" {
apiBase = "https://api.groq.com/openai/v1"
}
case cfg.Providers.VLLM.APIBase != "":
apiKey = cfg.Providers.VLLM.APIKey
apiBase = cfg.Providers.VLLM.APIBase
default:
if cfg.Providers.OpenRouter.APIKey != "" {
apiKey = cfg.Providers.OpenRouter.APIKey
if cfg.Providers.OpenRouter.APIBase != "" {
apiBase = cfg.Providers.OpenRouter.APIBase
} else {
apiBase = "https://openrouter.ai/api/v1"
}
} else {
return nil, fmt.Errorf("no API key configured for model: %s", model)
}
}
if apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
return nil, fmt.Errorf("no API key configured for provider (model: %s)", model)
}
if apiBase == "" {
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
}
return NewHTTPProvider(apiKey, apiBase), nil
}

52
pkg/providers/types.go Normal file
View File

@@ -0,0 +1,52 @@
package providers
import "context"
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type,omitempty"`
Function *FunctionCall `json:"function,omitempty"`
Name string `json:"name,omitempty"`
Arguments map[string]interface{} `json:"arguments,omitempty"`
}
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type LLMResponse struct {
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason"`
Usage *UsageInfo `json:"usage,omitempty"`
}
type UsageInfo struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type LLMProvider interface {
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
GetDefaultModel() string
}
type ToolDefinition struct {
Type string `json:"type"`
Function ToolFunctionDefinition `json:"function"`
}
type ToolFunctionDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}

143
pkg/session/manager.go Normal file
View File

@@ -0,0 +1,143 @@
package session
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/providers"
)
type Session struct {
Key string `json:"key"`
Messages []providers.Message `json:"messages"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
}
type SessionManager struct {
sessions map[string]*Session
mu sync.RWMutex
storage string
}
func NewSessionManager(storage string) *SessionManager {
sm := &SessionManager{
sessions: make(map[string]*Session),
storage: storage,
}
if storage != "" {
os.MkdirAll(storage, 0755)
sm.loadSessions()
}
return sm
}
func (sm *SessionManager) GetOrCreate(key string) *Session {
sm.mu.RLock()
session, ok := sm.sessions[key]
sm.mu.RUnlock()
if !ok {
sm.mu.Lock()
session = &Session{
Key: key,
Messages: []providers.Message{},
Created: time.Now(),
Updated: time.Now(),
}
sm.sessions[key] = session
sm.mu.Unlock()
}
return session
}
func (sm *SessionManager) AddMessage(sessionKey, role, content string) {
sm.mu.Lock()
defer sm.mu.Unlock()
session, ok := sm.sessions[sessionKey]
if !ok {
session = &Session{
Key: sessionKey,
Messages: []providers.Message{},
Created: time.Now(),
}
sm.sessions[sessionKey] = session
}
session.Messages = append(session.Messages, providers.Message{
Role: role,
Content: content,
})
session.Updated = time.Now()
}
func (sm *SessionManager) GetHistory(key string) []providers.Message {
sm.mu.RLock()
defer sm.mu.RUnlock()
session, ok := sm.sessions[key]
if !ok {
return []providers.Message{}
}
history := make([]providers.Message, len(session.Messages))
copy(history, session.Messages)
return history
}
func (sm *SessionManager) Save(session *Session) error {
if sm.storage == "" {
return nil
}
sm.mu.Lock()
defer sm.mu.Unlock()
sessionPath := filepath.Join(sm.storage, session.Key+".json")
data, err := json.MarshalIndent(session, "", " ")
if err != nil {
return err
}
return os.WriteFile(sessionPath, data, 0644)
}
func (sm *SessionManager) loadSessions() error {
files, err := os.ReadDir(sm.storage)
if err != nil {
return err
}
for _, file := range files {
if file.IsDir() {
continue
}
if filepath.Ext(file.Name()) != ".json" {
continue
}
sessionPath := filepath.Join(sm.storage, file.Name())
data, err := os.ReadFile(sessionPath)
if err != nil {
continue
}
var session Session
if err := json.Unmarshal(data, &session); err != nil {
continue
}
sm.sessions[session.Key] = &session
}
return nil
}

171
pkg/skills/installer.go Normal file
View File

@@ -0,0 +1,171 @@
package skills
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type SkillInstaller struct {
workspace string
}
type AvailableSkill struct {
Name string `json:"name"`
Repository string `json:"repository"`
Description string `json:"description"`
Author string `json:"author"`
Tags []string `json:"tags"`
}
type BuiltinSkill struct {
Name string `json:"name"`
Path string `json:"path"`
Enabled bool `json:"enabled"`
}
func NewSkillInstaller(workspace string) *SkillInstaller {
return &SkillInstaller{
workspace: workspace,
}
}
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo))
if _, err := os.Stat(skillDir); err == nil {
return fmt.Errorf("skill '%s' already exists", filepath.Base(repo))
}
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/SKILL.md", repo)
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch skill: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if err := os.MkdirAll(skillDir, 0755); err != nil {
return fmt.Errorf("failed to create skill directory: %w", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, body, 0644); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
return nil
}
func (si *SkillInstaller) Uninstall(skillName string) error {
skillDir := filepath.Join(si.workspace, "skills", skillName)
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
return fmt.Errorf("skill '%s' not found", skillName)
}
if err := os.RemoveAll(skillDir); err != nil {
return fmt.Errorf("failed to remove skill: %w", err)
}
return nil
}
func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableSkill, error) {
url := "https://raw.githubusercontent.com/sipeed/picoclaw-skills/main/skills.json"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to fetch skills list: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var skills []AvailableSkill
if err := json.Unmarshal(body, &skills); err != nil {
return nil, fmt.Errorf("failed to parse skills list: %w", err)
}
return skills, nil
}
func (si *SkillInstaller) ListBuiltinSkills() []BuiltinSkill {
builtinSkillsDir := filepath.Join(filepath.Dir(si.workspace), "picoclaw", "skills")
entries, err := os.ReadDir(builtinSkillsDir)
if err != nil {
return nil
}
var skills []BuiltinSkill
for _, entry := range entries {
if entry.IsDir() {
_ = entry
skillName := entry.Name()
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
data, err := os.ReadFile(skillFile)
description := ""
if err == nil {
content := string(data)
if idx := strings.Index(content, "\n"); idx > 0 {
firstLine := content[:idx]
if strings.Contains(firstLine, "description:") {
descLine := strings.Index(content[idx:], "\n")
if descLine > 0 {
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
}
}
}
}
// skill := BuiltinSkill{
// Name: skillName,
// Path: description,
// Enabled: true,
// }
status := "✓"
fmt.Printf(" %s %s\n", status, entry.Name())
if description != "" {
fmt.Printf(" %s\n", description)
}
}
}
return skills
}

306
pkg/skills/loader.go Normal file
View File

@@ -0,0 +1,306 @@
package skills
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
)
type SkillMetadata struct {
Name string `json:"name"`
Description string `json:"description"`
Always bool `json:"always"`
Requires *SkillRequirements `json:"requires,omitempty"`
}
type SkillRequirements struct {
Bins []string `json:"bins"`
Env []string `json:"env"`
}
type SkillInfo struct {
Name string `json:"name"`
Path string `json:"path"`
Source string `json:"source"`
Description string `json:"description"`
Available bool `json:"available"`
Missing string `json:"missing,omitempty"`
}
type SkillsLoader struct {
workspace string
workspaceSkills string
builtinSkills string
}
func NewSkillsLoader(workspace string, builtinSkills string) *SkillsLoader {
return &SkillsLoader{
workspace: workspace,
workspaceSkills: filepath.Join(workspace, "skills"),
builtinSkills: builtinSkills,
}
}
func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
skills := make([]SkillInfo, 0)
if sl.workspaceSkills != "" {
if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil {
for _, dir := range dirs {
if dir.IsDir() {
skillFile := filepath.Join(sl.workspaceSkills, dir.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); err == nil {
info := SkillInfo{
Name: dir.Name(),
Path: skillFile,
Source: "workspace",
}
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
info.Available = sl.checkRequirements(metadata.Requires)
if !info.Available {
info.Missing = sl.getMissingRequirements(metadata.Requires)
}
} else {
info.Available = true
}
skills = append(skills, info)
}
}
}
}
}
if sl.builtinSkills != "" {
if dirs, err := os.ReadDir(sl.builtinSkills); err == nil {
for _, dir := range dirs {
if dir.IsDir() {
skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); err == nil {
exists := false
for _, s := range skills {
if s.Name == dir.Name() && s.Source == "workspace" {
exists = true
break
}
}
if exists {
continue
}
info := SkillInfo{
Name: dir.Name(),
Path: skillFile,
Source: "builtin",
}
metadata := sl.getSkillMetadata(skillFile)
if metadata != nil {
info.Description = metadata.Description
info.Available = sl.checkRequirements(metadata.Requires)
if !info.Available {
info.Missing = sl.getMissingRequirements(metadata.Requires)
}
} else {
info.Available = true
}
skills = append(skills, info)
}
}
}
}
}
if filterUnavailable {
filtered := make([]SkillInfo, 0)
for _, s := range skills {
if s.Available {
filtered = append(filtered, s)
}
}
return filtered
}
return skills
}
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
if sl.workspaceSkills != "" {
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
return sl.stripFrontmatter(string(content)), true
}
}
if sl.builtinSkills != "" {
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
return sl.stripFrontmatter(string(content)), true
}
}
return "", false
}
func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string {
if len(skillNames) == 0 {
return ""
}
var parts []string
for _, name := range skillNames {
content, ok := sl.LoadSkill(name)
if ok {
parts = append(parts, fmt.Sprintf("### Skill: %s\n\n%s", name, content))
}
}
return strings.Join(parts, "\n\n---\n\n")
}
func (sl *SkillsLoader) BuildSkillsSummary() string {
allSkills := sl.ListSkills(false)
if len(allSkills) == 0 {
return ""
}
var lines []string
lines = append(lines, "<skills>")
for _, s := range allSkills {
escapedName := escapeXML(s.Name)
escapedDesc := escapeXML(s.Description)
escapedPath := escapeXML(s.Path)
available := "true"
if !s.Available {
available = "false"
}
lines = append(lines, fmt.Sprintf(" <skill available=\"%s\">", available))
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc))
lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath))
if !s.Available && s.Missing != "" {
escapedMissing := escapeXML(s.Missing)
lines = append(lines, fmt.Sprintf(" <requires>%s</requires>", escapedMissing))
}
lines = append(lines, " </skill>")
}
lines = append(lines, "</skills>")
return strings.Join(lines, "\n")
}
func (sl *SkillsLoader) GetAlwaysSkills() []string {
skills := sl.ListSkills(true)
var always []string
for _, s := range skills {
metadata := sl.getSkillMetadata(s.Path)
if metadata != nil && metadata.Always {
always = append(always, s.Name)
}
}
return always
}
func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
content, err := os.ReadFile(skillPath)
if err != nil {
return nil
}
frontmatter := sl.extractFrontmatter(string(content))
if frontmatter == "" {
return &SkillMetadata{
Name: filepath.Base(filepath.Dir(skillPath)),
}
}
var metadata struct {
Name string `json:"name"`
Description string `json:"description"`
Always bool `json:"always"`
Requires *SkillRequirements `json:"requires"`
}
if err := json.Unmarshal([]byte(frontmatter), &metadata); err != nil {
return nil
}
return &SkillMetadata{
Name: metadata.Name,
Description: metadata.Description,
Always: metadata.Always,
Requires: metadata.Requires,
}
}
func (sl *SkillsLoader) extractFrontmatter(content string) string {
re := regexp.MustCompile(`^---\n(.*?)\n---`)
match := re.FindStringSubmatch(content)
if len(match) > 1 {
return match[1]
}
return ""
}
func (sl *SkillsLoader) stripFrontmatter(content string) string {
re := regexp.MustCompile(`^---\n.*?\n---\n`)
return re.ReplaceAllString(content, "")
}
func (sl *SkillsLoader) checkRequirements(requires *SkillRequirements) bool {
if requires == nil {
return true
}
for _, bin := range requires.Bins {
if _, err := exec.LookPath(bin); err != nil {
continue
} else {
return true
}
}
for _, env := range requires.Env {
if os.Getenv(env) == "" {
return false
}
}
return true
}
func (sl *SkillsLoader) getMissingRequirements(requires *SkillRequirements) string {
if requires == nil {
return ""
}
var missing []string
for _, bin := range requires.Bins {
if _, err := exec.LookPath(bin); err != nil {
missing = append(missing, fmt.Sprintf("CLI: %s", bin))
}
}
for _, env := range requires.Env {
if os.Getenv(env) == "" {
missing = append(missing, fmt.Sprintf("ENV: %s", env))
}
}
return strings.Join(missing, ", ")
}
func escapeXML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}

21
pkg/tools/base.go Normal file
View File

@@ -0,0 +1,21 @@
package tools
import "context"
type Tool interface {
Name() string
Description() string
Parameters() map[string]interface{}
Execute(ctx context.Context, args map[string]interface{}) (string, error)
}
func ToolToSchema(tool Tool) map[string]interface{} {
return map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": tool.Name(),
"description": tool.Description(),
"parameters": tool.Parameters(),
},
}
}

148
pkg/tools/edit.go Normal file
View File

@@ -0,0 +1,148 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
type EditFileTool struct{}
func NewEditFileTool() *EditFileTool {
return &EditFileTool{}
}
func (t *EditFileTool) Name() string {
return "edit_file"
}
func (t *EditFileTool) Description() string {
return "Edit a file by replacing old_text with new_text. The old_text must exist exactly in the file."
}
func (t *EditFileTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "The file path to edit",
},
"old_text": map[string]interface{}{
"type": "string",
"description": "The exact text to find and replace",
},
"new_text": map[string]interface{}{
"type": "string",
"description": "The text to replace with",
},
},
"required": []string{"path", "old_text", "new_text"},
}
}
func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
path, ok := args["path"].(string)
if !ok {
return "", fmt.Errorf("path is required")
}
oldText, ok := args["old_text"].(string)
if !ok {
return "", fmt.Errorf("old_text is required")
}
newText, ok := args["new_text"].(string)
if !ok {
return "", fmt.Errorf("new_text is required")
}
filePath := filepath.Clean(path)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return "", fmt.Errorf("file not found: %s", path)
}
content, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
contentStr := string(content)
if !strings.Contains(contentStr, oldText) {
return "", fmt.Errorf("old_text not found in file. Make sure it matches exactly")
}
count := strings.Count(contentStr, oldText)
if count > 1 {
return "", fmt.Errorf("old_text appears %d times. Please provide more context to make it unique", count)
}
newContent := strings.Replace(contentStr, oldText, newText, 1)
if err := os.WriteFile(filePath, []byte(newContent), 0644); err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
return fmt.Sprintf("Successfully edited %s", path), nil
}
type AppendFileTool struct{}
func NewAppendFileTool() *AppendFileTool {
return &AppendFileTool{}
}
func (t *AppendFileTool) Name() string {
return "append_file"
}
func (t *AppendFileTool) Description() string {
return "Append content to the end of a file"
}
func (t *AppendFileTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "The file path to append to",
},
"content": map[string]interface{}{
"type": "string",
"description": "The content to append",
},
},
"required": []string{"path", "content"},
}
}
func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
path, ok := args["path"].(string)
if !ok {
return "", fmt.Errorf("path is required")
}
content, ok := args["content"].(string)
if !ok {
return "", fmt.Errorf("content is required")
}
filePath := filepath.Clean(path)
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return "", fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
if _, err := f.WriteString(content); err != nil {
return "", fmt.Errorf("failed to append to file: %w", err)
}
return fmt.Sprintf("Successfully appended to %s", path), nil
}

141
pkg/tools/filesystem.go Normal file
View File

@@ -0,0 +1,141 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
)
type ReadFileTool struct{}
func (t *ReadFileTool) Name() string {
return "read_file"
}
func (t *ReadFileTool) Description() string {
return "Read the contents of a file"
}
func (t *ReadFileTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "Path to the file to read",
},
},
"required": []string{"path"},
}
}
func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
path, ok := args["path"].(string)
if !ok {
return "", fmt.Errorf("path is required")
}
content, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("failed to read file: %w", err)
}
return string(content), nil
}
type WriteFileTool struct{}
func (t *WriteFileTool) Name() string {
return "write_file"
}
func (t *WriteFileTool) Description() string {
return "Write content to a file"
}
func (t *WriteFileTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "Path to the file to write",
},
"content": map[string]interface{}{
"type": "string",
"description": "Content to write to the file",
},
},
"required": []string{"path", "content"},
}
}
func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
path, ok := args["path"].(string)
if !ok {
return "", fmt.Errorf("path is required")
}
content, ok := args["content"].(string)
if !ok {
return "", fmt.Errorf("content is required")
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("failed to create directory: %w", err)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return "", fmt.Errorf("failed to write file: %w", err)
}
return "File written successfully", nil
}
type ListDirTool struct{}
func (t *ListDirTool) Name() string {
return "list_dir"
}
func (t *ListDirTool) Description() string {
return "List files and directories in a path"
}
func (t *ListDirTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"path": map[string]interface{}{
"type": "string",
"description": "Path to list",
},
},
"required": []string{"path"},
}
}
func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
path, ok := args["path"].(string)
if !ok {
path = "."
}
entries, err := os.ReadDir(path)
if err != nil {
return "", fmt.Errorf("failed to read directory: %w", err)
}
result := ""
for _, entry := range entries {
if entry.IsDir() {
result += "DIR: " + entry.Name() + "\n"
} else {
result += "FILE: " + entry.Name() + "\n"
}
}
return result, nil
}

87
pkg/tools/message.go Normal file
View File

@@ -0,0 +1,87 @@
package tools
import (
"context"
"fmt"
)
type SendCallback func(channel, chatID, content string) error
type MessageTool struct {
sendCallback SendCallback
defaultChannel string
defaultChatID string
}
func NewMessageTool() *MessageTool {
return &MessageTool{}
}
func (t *MessageTool) Name() string {
return "message"
}
func (t *MessageTool) Description() string {
return "Send a message to user on a chat channel. Use this when you want to communicate something."
}
func (t *MessageTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"content": map[string]interface{}{
"type": "string",
"description": "The message content to send",
},
"channel": map[string]interface{}{
"type": "string",
"description": "Optional: target channel (telegram, whatsapp, etc.)",
},
"chat_id": map[string]interface{}{
"type": "string",
"description": "Optional: target chat/user ID",
},
},
"required": []string{"content"},
}
}
func (t *MessageTool) SetContext(channel, chatID string) {
t.defaultChannel = channel
t.defaultChatID = chatID
}
func (t *MessageTool) SetSendCallback(callback SendCallback) {
t.sendCallback = callback
}
func (t *MessageTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
content, ok := args["content"].(string)
if !ok {
return "", fmt.Errorf("content is required")
}
channel, _ := args["channel"].(string)
chatID, _ := args["chat_id"].(string)
if channel == "" {
channel = t.defaultChannel
}
if chatID == "" {
chatID = t.defaultChatID
}
if channel == "" || chatID == "" {
return "Error: No target channel/chat specified", nil
}
if t.sendCallback == nil {
return "Error: Message sending not configured", nil
}
if err := t.sendCallback(channel, chatID, content); err != nil {
return fmt.Sprintf("Error sending message: %v", err), nil
}
return fmt.Sprintf("Message sent to %s:%s", channel, chatID), nil
}

50
pkg/tools/registry.go Normal file
View File

@@ -0,0 +1,50 @@
package tools
import (
"context"
"fmt"
"sync"
)
type ToolRegistry struct {
tools map[string]Tool
mu sync.RWMutex
}
func NewToolRegistry() *ToolRegistry {
return &ToolRegistry{
tools: make(map[string]Tool),
}
}
func (r *ToolRegistry) Register(tool Tool) {
r.mu.Lock()
defer r.mu.Unlock()
r.tools[tool.Name()] = tool
}
func (r *ToolRegistry) Get(name string) (Tool, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
tool, ok := r.tools[name]
return tool, ok
}
func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) (string, error) {
tool, ok := r.Get(name)
if !ok {
return "", fmt.Errorf("tool '%s' not found", name)
}
return tool.Execute(ctx, args)
}
func (r *ToolRegistry) GetDefinitions() []map[string]interface{} {
r.mu.RLock()
defer r.mu.RUnlock()
definitions := make([]map[string]interface{}, 0, len(r.tools))
for _, tool := range r.tools {
definitions = append(definitions, ToolToSchema(tool))
}
return definitions
}

202
pkg/tools/shell.go Normal file
View File

@@ -0,0 +1,202 @@
package tools
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
type ExecTool struct {
workingDir string
timeout time.Duration
denyPatterns []*regexp.Regexp
allowPatterns []*regexp.Regexp
restrictToWorkspace bool
}
func NewExecTool(workingDir string) *ExecTool {
denyPatterns := []*regexp.Regexp{
regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
regexp.MustCompile(`\bdel\s+/[fq]\b`),
regexp.MustCompile(`\brmdir\s+/s\b`),
regexp.MustCompile(`\b(format|mkfs|diskpart)\b`),
regexp.MustCompile(`\bdd\s+if=`),
regexp.MustCompile(`>\s*/dev/sd`),
regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`),
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
}
return &ExecTool{
workingDir: workingDir,
timeout: 60 * time.Second,
denyPatterns: denyPatterns,
allowPatterns: nil,
restrictToWorkspace: false,
}
}
func (t *ExecTool) Name() string {
return "exec"
}
func (t *ExecTool) Description() string {
return "Execute a shell command and return its output. Use with caution."
}
func (t *ExecTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"command": map[string]interface{}{
"type": "string",
"description": "The shell command to execute",
},
"working_dir": map[string]interface{}{
"type": "string",
"description": "Optional working directory for the command",
},
},
"required": []string{"command"},
}
}
func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
command, ok := args["command"].(string)
if !ok {
return "", fmt.Errorf("command is required")
}
cwd := t.workingDir
if wd, ok := args["working_dir"].(string); ok && wd != "" {
cwd = wd
}
if cwd == "" {
wd, err := os.Getwd()
if err == nil {
cwd = wd
}
}
if guardError := t.guardCommand(command, cwd); guardError != "" {
return fmt.Sprintf("Error: %s", guardError), nil
}
cmdCtx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
cmd := exec.CommandContext(cmdCtx, "sh", "-c", command)
if cwd != "" {
cmd.Dir = cwd
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := stdout.String()
if stderr.Len() > 0 {
output += "\nSTDERR:\n" + stderr.String()
}
if err != nil {
if cmdCtx.Err() == context.DeadlineExceeded {
return fmt.Sprintf("Error: Command timed out after %v", t.timeout), nil
}
output += fmt.Sprintf("\nExit code: %v", err)
}
if output == "" {
output = "(no output)"
}
maxLen := 10000
if len(output) > maxLen {
output = output[:maxLen] + fmt.Sprintf("\n... (truncated, %d more chars)", len(output)-maxLen)
}
return output, nil
}
func (t *ExecTool) guardCommand(command, cwd string) string {
cmd := strings.TrimSpace(command)
lower := strings.ToLower(cmd)
for _, pattern := range t.denyPatterns {
if pattern.MatchString(lower) {
return "Command blocked by safety guard (dangerous pattern detected)"
}
}
if len(t.allowPatterns) > 0 {
allowed := false
for _, pattern := range t.allowPatterns {
if pattern.MatchString(lower) {
allowed = true
break
}
}
if !allowed {
return "Command blocked by safety guard (not in allowlist)"
}
}
if t.restrictToWorkspace {
if strings.Contains(cmd, "..\\") || strings.Contains(cmd, "../") {
return "Command blocked by safety guard (path traversal detected)"
}
cwdPath, err := filepath.Abs(cwd)
if err != nil {
return ""
}
pathPattern := regexp.MustCompile(`[A-Za-z]:\\[^\\\"']+|/[^\s\"']+`)
matches := pathPattern.FindAllString(cmd, -1)
for _, raw := range matches {
p, err := filepath.Abs(raw)
if err != nil {
continue
}
rel, err := filepath.Rel(cwdPath, p)
if err != nil {
continue
}
if strings.HasPrefix(rel, "..") {
return "Command blocked by safety guard (path outside working dir)"
}
}
}
return ""
}
func (t *ExecTool) SetTimeout(timeout time.Duration) {
t.timeout = timeout
}
func (t *ExecTool) SetRestrictToWorkspace(restrict bool) {
t.restrictToWorkspace = restrict
}
func (t *ExecTool) SetAllowPatterns(patterns []string) error {
t.allowPatterns = make([]*regexp.Regexp, 0, len(patterns))
for _, p := range patterns {
re, err := regexp.Compile(p)
if err != nil {
return fmt.Errorf("invalid allow pattern %q: %w", p, err)
}
t.allowPatterns = append(t.allowPatterns, re)
}
return nil
}

70
pkg/tools/spawn.go Normal file
View File

@@ -0,0 +1,70 @@
package tools
import (
"context"
"fmt"
)
type SpawnTool struct {
manager *SubagentManager
originChannel string
originChatID string
}
func NewSpawnTool(manager *SubagentManager) *SpawnTool {
return &SpawnTool{
manager: manager,
originChannel: "cli",
originChatID: "direct",
}
}
func (t *SpawnTool) Name() string {
return "spawn"
}
func (t *SpawnTool) Description() string {
return "Spawn a subagent to handle a task in the background. Use this for complex or time-consuming tasks that can run independently. The subagent will complete the task and report back when done."
}
func (t *SpawnTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"task": map[string]interface{}{
"type": "string",
"description": "The task for subagent to complete",
},
"label": map[string]interface{}{
"type": "string",
"description": "Optional short label for the task (for display)",
},
},
"required": []string{"task"},
}
}
func (t *SpawnTool) SetContext(channel, chatID string) {
t.originChannel = channel
t.originChatID = chatID
}
func (t *SpawnTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
task, ok := args["task"].(string)
if !ok {
return "", fmt.Errorf("task is required")
}
label, _ := args["label"].(string)
if t.manager == nil {
return "Error: Subagent manager not configured", nil
}
result, err := t.manager.Spawn(ctx, task, label, t.originChannel, t.originChatID)
if err != nil {
return "", fmt.Errorf("failed to spawn subagent: %w", err)
}
return result, nil
}

111
pkg/tools/subagent.go Normal file
View File

@@ -0,0 +1,111 @@
package tools
import (
"context"
"fmt"
"sync"
"time"
)
type SubagentTask struct {
ID string
Task string
Label string
OriginChannel string
OriginChatID string
Status string
Result string
Created int64
}
type SubagentManager struct {
tasks map[string]*SubagentTask
mu sync.RWMutex
provider LLMProvider
workspace string
nextID int
}
func NewSubagentManager(provider LLMProvider, workspace string) *SubagentManager {
return &SubagentManager{
tasks: make(map[string]*SubagentTask),
provider: provider,
workspace: workspace,
nextID: 1,
}
}
func (sm *SubagentManager) Spawn(ctx context.Context, task, label, originChannel, originChatID string) (string, error) {
sm.mu.Lock()
defer sm.mu.Unlock()
taskID := fmt.Sprintf("subagent-%d", sm.nextID)
sm.nextID++
subagentTask := &SubagentTask{
ID: taskID,
Task: task,
Label: label,
OriginChannel: originChannel,
OriginChatID: originChatID,
Status: "running",
Created: time.Now().UnixMilli(),
}
sm.tasks[taskID] = subagentTask
go sm.runTask(ctx, subagentTask)
if label != "" {
return fmt.Sprintf("Spawned subagent '%s' for task: %s", label, task), nil
}
return fmt.Sprintf("Spawned subagent for task: %s", task), nil
}
func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask) {
task.Status = "running"
task.Created = time.Now().UnixMilli()
messages := []Message{
{
Role: "system",
Content: "You are a subagent. Complete the given task independently and report the result.",
},
{
Role: "user",
Content: task.Task,
},
}
response, err := sm.provider.Chat(ctx, messages, nil, sm.provider.GetDefaultModel(), map[string]interface{}{
"max_tokens": 4096,
})
sm.mu.Lock()
defer sm.mu.Unlock()
if err != nil {
task.Status = "failed"
task.Result = fmt.Sprintf("Error: %v", err)
} else {
task.Status = "completed"
task.Result = response.Content
}
}
func (sm *SubagentManager) GetTask(taskID string) (*SubagentTask, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
task, ok := sm.tasks[taskID]
return task, ok
}
func (sm *SubagentManager) ListTasks() []*SubagentTask {
sm.mu.RLock()
defer sm.mu.RUnlock()
tasks := make([]*SubagentTask, 0, len(sm.tasks))
for _, task := range sm.tasks {
tasks = append(tasks, task)
}
return tasks
}

52
pkg/tools/types.go Normal file
View File

@@ -0,0 +1,52 @@
package tools
import "context"
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function *FunctionCall `json:"function,omitempty"`
Name string `json:"name,omitempty"`
Arguments map[string]interface{} `json:"arguments,omitempty"`
}
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type LLMResponse struct {
Content string `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
FinishReason string `json:"finish_reason"`
Usage *UsageInfo `json:"usage,omitempty"`
}
type UsageInfo struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
type LLMProvider interface {
Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error)
GetDefaultModel() string
}
type ToolDefinition struct {
Type string `json:"type"`
Function ToolFunctionDefinition `json:"function"`
}
type ToolFunctionDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}

298
pkg/tools/web.go Normal file
View File

@@ -0,0 +1,298 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
)
const (
userAgent = "Mozilla/5.0 (compatible; picoclaw/1.0)"
)
type WebSearchTool struct {
apiKey string
maxResults int
}
func NewWebSearchTool(apiKey string, maxResults int) *WebSearchTool {
if maxResults <= 0 || maxResults > 10 {
maxResults = 5
}
return &WebSearchTool{
apiKey: apiKey,
maxResults: maxResults,
}
}
func (t *WebSearchTool) Name() string {
return "web_search"
}
func (t *WebSearchTool) Description() string {
return "Search the web. Returns titles, URLs, and snippets."
}
func (t *WebSearchTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "Search query",
},
"count": map[string]interface{}{
"type": "integer",
"description": "Number of results (1-10)",
"minimum": 1.0,
"maximum": 10.0,
},
},
"required": []string{"query"},
}
}
func (t *WebSearchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
if t.apiKey == "" {
return "Error: BRAVE_API_KEY not configured", nil
}
query, ok := args["query"].(string)
if !ok {
return "", fmt.Errorf("query is required")
}
count := t.maxResults
if c, ok := args["count"].(float64); ok {
if int(c) > 0 && int(c) <= 10 {
count = int(c)
}
}
searchURL := fmt.Sprintf("https://api.search.brave.com/res/v1/web/search?q=%s&count=%d",
url.QueryEscape(query), count)
req, err := http.NewRequestWithContext(ctx, "GET", searchURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", t.apiKey)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
var searchResp struct {
Web struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
} `json:"results"`
} `json:"web"`
}
if err := json.Unmarshal(body, &searchResp); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
results := searchResp.Web.Results
if len(results) == 0 {
return fmt.Sprintf("No results for: %s", query), nil
}
var lines []string
lines = append(lines, fmt.Sprintf("Results for: %s", query))
for i, item := range results {
if i >= count {
break
}
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.URL))
if item.Description != "" {
lines = append(lines, fmt.Sprintf(" %s", item.Description))
}
}
return strings.Join(lines, "\n"), nil
}
type WebFetchTool struct {
maxChars int
}
func NewWebFetchTool(maxChars int) *WebFetchTool {
if maxChars <= 0 {
maxChars = 50000
}
return &WebFetchTool{
maxChars: maxChars,
}
}
func (t *WebFetchTool) Name() string {
return "web_fetch"
}
func (t *WebFetchTool) Description() string {
return "Fetch a URL and extract readable content (HTML to text). Use this to get weather info, news, articles, or any web content."
}
func (t *WebFetchTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "URL to fetch",
},
"maxChars": map[string]interface{}{
"type": "integer",
"description": "Maximum characters to extract",
"minimum": 100.0,
},
},
"required": []string{"url"},
}
}
func (t *WebFetchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) {
urlStr, ok := args["url"].(string)
if !ok {
return "", fmt.Errorf("url is required")
}
parsedURL, err := url.Parse(urlStr)
if err != nil {
return "", fmt.Errorf("invalid URL: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return "", fmt.Errorf("only http/https URLs are allowed")
}
if parsedURL.Host == "" {
return "", fmt.Errorf("missing domain in URL")
}
maxChars := t.maxChars
if mc, ok := args["maxChars"].(float64); ok {
if int(mc) > 100 {
maxChars = int(mc)
}
}
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", userAgent)
client := &http.Client{
Timeout: 60 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 30 * time.Second,
DisableCompression: false,
TLSHandshakeTimeout: 15 * time.Second,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return fmt.Errorf("stopped after 5 redirects")
}
return nil
},
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
contentType := resp.Header.Get("Content-Type")
var text, extractor string
if strings.Contains(contentType, "application/json") {
var jsonData interface{}
if err := json.Unmarshal(body, &jsonData); err == nil {
formatted, _ := json.MarshalIndent(jsonData, "", " ")
text = string(formatted)
extractor = "json"
} else {
text = string(body)
extractor = "raw"
}
} else if strings.Contains(contentType, "text/html") || len(body) > 0 &&
(strings.HasPrefix(string(body), "<!DOCTYPE") || strings.HasPrefix(strings.ToLower(string(body)), "<html")) {
text = t.extractText(string(body))
extractor = "text"
} else {
text = string(body)
extractor = "raw"
}
truncated := len(text) > maxChars
if truncated {
text = text[:maxChars]
}
result := map[string]interface{}{
"url": urlStr,
"status": resp.StatusCode,
"extractor": extractor,
"truncated": truncated,
"length": len(text),
"text": text,
}
resultJSON, _ := json.MarshalIndent(result, "", " ")
return string(resultJSON), nil
}
func (t *WebFetchTool) extractText(htmlContent string) string {
re := regexp.MustCompile(`<script[\s\S]*?</script>`)
result := re.ReplaceAllLiteralString(htmlContent, "")
re = regexp.MustCompile(`<style[\s\S]*?</style>`)
result = re.ReplaceAllLiteralString(result, "")
re = regexp.MustCompile(`<[^>]+>`)
result = re.ReplaceAllLiteralString(result, "")
result = strings.TrimSpace(result)
re = regexp.MustCompile(`\s+`)
result = re.ReplaceAllLiteralString(result, " ")
lines := strings.Split(result, "\n")
var cleanLines []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line != "" {
cleanLines = append(cleanLines, line)
}
}
return strings.Join(cleanLines, "\n")
}

116
pkg/voice/transcriber.go Normal file
View File

@@ -0,0 +1,116 @@
package voice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"time"
)
type GroqTranscriber struct {
apiKey string
apiBase string
httpClient *http.Client
}
type TranscriptionResponse struct {
Text string `json:"text"`
Language string `json:"language,omitempty"`
Duration float64 `json:"duration,omitempty"`
}
func NewGroqTranscriber(apiKey string) *GroqTranscriber {
apiBase := "https://api.groq.com/openai/v1"
return &GroqTranscriber{
apiKey: apiKey,
apiBase: apiBase,
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) {
log.Printf("Starting transcription for audio file: %s", audioFilePath)
audioFile, err := os.Open(audioFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open audio file: %w", err)
}
defer audioFile.Close()
fileInfo, err := audioFile.Stat()
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
var requestBody bytes.Buffer
writer := multipart.NewWriter(&requestBody)
part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath))
if err != nil {
return nil, fmt.Errorf("failed to create form file: %w", err)
}
if _, err := io.Copy(part, audioFile); err != nil {
return nil, fmt.Errorf("failed to copy file content: %w", err)
}
if err := writer.WriteField("model", "whisper-large-v3"); err != nil {
return nil, fmt.Errorf("failed to write model field: %w", err)
}
if err := writer.WriteField("response_format", "json"); err != nil {
return nil, fmt.Errorf("failed to write response_format field: %w", err)
}
if err := writer.Close(); err != nil {
return nil, fmt.Errorf("failed to close multipart writer: %w", err)
}
url := t.apiBase + "/audio/transcriptions"
req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+t.apiKey)
log.Printf("Sending transcription request to Groq API (file size: %d bytes)", fileInfo.Size())
resp, err := t.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body))
}
var result TranscriptionResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
log.Printf("Transcription completed successfully (text length: %d chars)", len(result.Text))
return &result, nil
}
func (t *GroqTranscriber) IsAvailable() bool {
return t.apiKey != ""
}