This commit is contained in:
Bernardo 2026-03-10 21:53:26 +01:00
parent 8ce92a4ce7
commit d5aa7569cc
41 changed files with 3217 additions and 307 deletions

View file

@ -143,6 +143,13 @@ func gatewayCmd(debug bool) error {
logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()})
}
// Wire up speech synthesis if ElevenLabs is configured.
if cfg.Tools.ElevenLabs.Enabled && cfg.Tools.ElevenLabs.APIKey != "" {
elSynth := voice.NewElevenLabsSynthesizer(cfg.Tools.ElevenLabs.APIKey, cfg.Tools.ElevenLabs.VoiceID)
agentLoop.AddSynthesizer(elSynth)
logger.InfoCF("voice", "Speech synthesis enabled (ElevenLabs)", map[string]any{"voice_id": cfg.Tools.ElevenLabs.VoiceID})
}
enabledChannels := channelManager.GetEnabledChannels()
if len(enabledChannels) > 0 {
fmt.Printf("✓ Channels enabled: %s\n", enabledChannels)
@ -161,6 +168,14 @@ func gatewayCmd(debug bool) error {
}
fmt.Println("✓ Cron service started")
// Setup proactive service
proactiveService := agent.NewProactiveService(cronService, cfg.Tools.Proactive)
if err := proactiveService.Start(); err != nil {
fmt.Printf("Error starting proactive service: %v\n", err)
}
agentLoop.SetProactiveService(proactiveService)
fmt.Println("✓ Proactive service started")
if err := heartbeatService.Start(); err != nil {
fmt.Printf("Error starting heartbeat service: %v\n", err)
}

3
go.mod
View file

@ -30,6 +30,9 @@ require (
google.golang.org/protobuf v1.36.11
maunium.net/go/mautrix v0.26.3
modernc.org/sqlite v1.46.1
github.com/jung-kurt/gofpdf/v2 v2.17.2
github.com/chromedp/chromedp v0.10.0
github.com/disintegration/imaging v1.6.2
)
require (

View file

@ -41,6 +41,9 @@ type ContextBuilder struct {
// build time. This catches nested file creations/deletions/mtime changes
// that may not update the top-level skill root directory mtime.
skillFilesAtCache map[string]time.Time
writingStyle string
autoReplyEnabled bool
}
func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuilder {
@ -49,6 +52,12 @@ func (cb *ContextBuilder) WithToolDiscovery(useBM25, useRegex bool) *ContextBuil
return cb
}
func (cb *ContextBuilder) WithInteraction(style string, autoReply bool) *ContextBuilder {
cb.writingStyle = style
cb.autoReplyEnabled = autoReply
return cb
}
func getGlobalConfigDir() string {
if home := os.Getenv("PICOCLAW_HOME"); home != "" {
return home
@ -81,10 +90,20 @@ func (cb *ContextBuilder) getIdentity() string {
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
toolDiscovery := cb.getDiscoveryRule()
styleStr := ""
if cb.writingStyle != "" {
styleStr = fmt.Sprintf("\n## Writing Style\nYou MUST emulate the following writing style: %s\n", cb.writingStyle)
}
modeStr := ""
if cb.autoReplyEnabled {
modeStr = "\n## Auto-Reply Mode\nYou are currently in AUTO-REPLY mode. You are processing messages proactively. If you need to take an action that requires user approval (e.g., deleting files, sending messages to others), you MUST propose the action and wait for a user response."
}
return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant.
%s%s
## Workspace
Your workspace is at: %s
- Memory: %s/memory/MEMORY.md
@ -119,7 +138,9 @@ func (cb *ContextBuilder) getDiscoveryRule() string {
}
return fmt.Sprintf(
`5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.`,
`5. **Tool Discovery** - Your visible tools are limited to save memory, but a vast hidden library exists. If you lack the right tool for a task, BEFORE giving up, you MUST search using the %s tool. Do not refuse a request unless the search returns nothing. Found tools will temporarily unlock for your next turn.
6. **Voice Messages** - Inbound messages may contain transcriptions from voice notes, formatted as `[voice: Transcription text]`. Treat these as direct instructions from the user.`,
strings.Join(toolNames, " or "),
)
}
@ -224,6 +245,7 @@ func (cb *ContextBuilder) sourcePaths() []string {
filepath.Join(cb.workspace, "USER.md"),
filepath.Join(cb.workspace, "IDENTITY.md"),
filepath.Join(cb.workspace, "memory", "MEMORY.md"),
filepath.Join(cb.workspace, "memory", "COMMUNICATIONS.md"),
}
}

View file

@ -79,9 +79,6 @@ func NewAgentInstance(
if cfg.Tools.IsToolEnabled("list_dir") {
toolsRegistry.Register(tools.NewListDirTool(workspace, readRestrict, allowReadPaths))
}
if cfg.Tools.IsToolEnabled("google") {
toolsRegistry.Register(&tools.GoogleTool{})
}
if cfg.Tools.IsToolEnabled("exec") {
execTool, err := tools.NewExecToolWithConfig(workspace, restrict, cfg)
if err != nil {
@ -105,6 +102,15 @@ func NewAgentInstance(
if cfg.Tools.IsToolEnabled("append_file") {
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths))
}
if cfg.Tools.IsToolEnabled("pdf") {
toolsRegistry.Register(tools.NewPDFTool(workspace, restrict, cfg.MediaStore))
}
if cfg.Tools.IsToolEnabled("browser") {
toolsRegistry.Register(tools.NewBrowserTool(workspace, cfg.MediaStore))
}
if cfg.Tools.IsToolEnabled("image") {
toolsRegistry.Register(tools.NewImageTool(workspace, cfg.MediaStore))
}
sessionsDir := filepath.Join(workspace, "sessions")
sessionsManager := session.NewSessionManager(sessionsDir)
@ -113,7 +119,7 @@ func NewAgentInstance(
contextBuilder := NewContextBuilder(workspace).WithToolDiscovery(
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseBM25,
mcpDiscoveryActive && cfg.Tools.MCP.Discovery.UseRegex,
)
).WithInteraction(cfg.Tools.Interaction.WritingStyle, cfg.Tools.Interaction.AutoReplyEnabled)
agentID := routing.DefaultAgentID
agentName := ""

View file

@ -8,9 +8,11 @@ package agent
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
@ -19,6 +21,7 @@ import (
"time"
"unicode/utf8"
"golang.org/x/sync/errgroup"
"golang.org/x/sync/singleflight"
"github.com/sipeed/picoclaw/pkg/bus"
@ -26,6 +29,7 @@ import (
"github.com/sipeed/picoclaw/pkg/commands"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/constants"
"github.com/sipeed/picoclaw/pkg/health"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/mcp"
"github.com/sipeed/picoclaw/pkg/media"
@ -50,6 +54,8 @@ type AgentLoop struct {
mediaStore media.MediaStore
transcriber voice.Transcriber
cmdRegistry *commands.Registry
proactiveService *ProactiveService
synthesizers map[string]voice.Synthesizer
version string
sf singleflight.Group
wg sync.WaitGroup
@ -66,6 +72,7 @@ type processOptions struct {
EnableSummary bool // Whether to trigger summarization
SendResponse bool // Whether to send response via bus
NoHistory bool // If true, don't load session history (for heartbeat)
SenderID string // From inbound message (e.g., "cron")
}
const (
@ -205,6 +212,18 @@ func registerSharedTools(
skills_enabled := cfg.Tools.IsToolEnabled("skills")
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
// WhatsApp and Google tools (shared across agents)
if cfg.Channels.WhatsApp.Enabled {
agent.Tools.Register(tools.NewWhatsAppTool(al))
}
if cfg.Tools.IsToolEnabled("google") {
agent.Tools.Register(tools.NewGoogleTool(al))
}
if cfg.Tools.IsToolEnabled("speech") || cfg.Tools.ElevenLabs.Enabled {
agent.Tools.Register(tools.NewSpeechTool(al))
}
if skills_enabled && (find_skills_enable || install_skills_enable) {
registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{
MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches,
@ -217,6 +236,7 @@ func registerSharedTools(
time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second,
)
agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache))
agent.Tools.Register(tools.NewInspectSkillTool(registryMgr))
}
if install_skills_enable {
@ -450,6 +470,52 @@ func (al *AgentLoop) SetChannelManager(cm *channels.Manager) {
al.channelManager = cm
}
func (al *AgentLoop) GetChannel(name string) (any, bool) {
if al.channelManager == nil {
return nil, false
}
return al.channelManager.GetChannel(name)
}
func (al *AgentLoop) GetMemoryStore() any {
return al.memory
}
func (al *AgentLoop) SetProactiveService(ps *ProactiveService) {
al.proactiveService = ps
}
func (al *AgentLoop) AddSynthesizer(s voice.Synthesizer) {
if al.synthesizers == nil {
al.synthesizers = make(map[string]voice.Synthesizer)
}
al.synthesizers[s.Name()] = s
}
func (al *AgentLoop) GetSynthesizer(name string) (any, bool) {
if name == "" {
// Return first available as default
for _, s := range al.synthesizers {
return s, true
}
return nil, false
}
s, ok := al.synthesizers[name]
return s, ok
}
func (al *AgentLoop) GetMediaStore() media.MediaStore {
return al.mediaStore
}
func (al *AgentLoop) GetWorkspace() string {
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent != nil && defaultAgent.Workspace != "" {
return defaultAgent.Workspace
}
return al.cfg.WorkspacePath()
}
// SetMediaStore injects a MediaStore for media lifecycle management.
func (al *AgentLoop) SetMediaStore(s media.MediaStore) {
al.mediaStore = s
@ -673,6 +739,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
DefaultResponse: defaultResponse,
EnableSummary: true,
SendResponse: false,
SenderID: msg.SenderID,
}
// Check for pending authorization response
@ -1027,6 +1094,8 @@ func (al *AgentLoop) runLLMIteration(
}
}
messages = al.resolveMediaReferences(messages)
callLLM := func() (*providers.LLMResponse, error) {
if len(activeCandidates) > 1 && al.fallback != nil {
fbResult, fbErr := al.fallback.Execute(
@ -1155,6 +1224,13 @@ func (al *AgentLoop) runLLMIteration(
// Telemetry: Log iteration performance
duration := time.Since(iterStart)
health.DefaultMetrics.RecordLatency("llm_duration_"+activeModel, duration.Milliseconds())
if response.Usage != nil {
health.DefaultMetrics.RecordCounter("tokens_prompt", int64(response.Usage.PromptTokens))
health.DefaultMetrics.RecordCounter("tokens_completion", int64(response.Usage.CompletionTokens))
health.DefaultMetrics.RecordCounter("tokens_total", int64(response.Usage.TotalTokens))
}
logger.InfoCF("agent", "Iteration performance", map[string]any{
"agent_id": agent.ID,
"model": activeModel,
@ -1228,38 +1304,51 @@ func (al *AgentLoop) runLLMIteration(
// Save assistant message with tool calls to session
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
// Execute tool calls in parallel
// Execute tool calls in parallel using errgroup for structured concurrency.
g, gCtx := errgroup.WithContext(ctx)
type indexedAgentResult struct {
result *tools.ToolResult
tc providers.ToolCall
}
agentResults := make([]indexedAgentResult, len(normalizedToolCalls))
var wg sync.WaitGroup
// Check for approval gating if it's a proactive turn
approvalRequired := al.cfg.Tools.Interaction.ApprovalRequired
isProactive := (opts.SenderID == "cron")
for i, tc := range normalizedToolCalls {
agentResults[i].tc = tc
idx, toolCall := i, tc
agentResults[idx].tc = toolCall
wg.Add(1)
go func(idx int, tc providers.ToolCall) {
defer wg.Done()
// If it's a proactive turn and approval is required, intercept tool call.
// We only block tools that are NOT read-only.
// For simplicity here, we'll block everything that isn't explicitly safe
// if the user requested "never go off without explicit request".
if isProactive && approvalRequired {
logger.InfoCF("agent", "Intercepting tool call for approval",
map[string]any{"tool": toolCall.Name, "agent_id": agent.ID})
agentResults[idx].result = &tools.ToolResult{
Silent: false,
ForUser: fmt.Sprintf("⚠️ I need your approval to execute: %s(%v). Should I proceed?",
toolCall.Name, toolCall.Arguments),
ForLLM: "INTERCEPTED: Execution requires explicit user approval in chat. Propose this action to the user instead of executing it.",
}
continue
}
argsJSON, _ := json.Marshal(tc.Arguments)
g.Go(func() error {
argsJSON, _ := json.Marshal(toolCall.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", tc.Name, argsPreview),
logger.InfoCF("agent", fmt.Sprintf("Tool call: %s(%s)", toolCall.Name, argsPreview),
map[string]any{
"agent_id": agent.ID,
"tool": tc.Name,
"tool": toolCall.Name,
"iteration": iteration,
})
// Create async callback for tools that implement AsyncExecutor.
// When the background work completes, this publishes the result
// as an inbound system message so processSystemMessage routes it
// back to the user via the normal agent loop.
asyncCallback := func(_ context.Context, result *tools.ToolResult) {
// Send ForUser content directly to the user (immediate feedback),
// mirroring the synchronous tool execution path.
if !result.Silent && result.ForUser != "" {
outCtx, outCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer outCancel()
@ -1270,7 +1359,6 @@ func (al *AgentLoop) runLLMIteration(
})
}
// Determine content for the agent loop (ForLLM or error).
content := result.ForLLM
if content == "" && result.Err != nil {
content = result.Err.Error()
@ -1281,7 +1369,7 @@ func (al *AgentLoop) runLLMIteration(
logger.InfoCF("agent", "Async tool completed, publishing result",
map[string]any{
"tool": tc.Name,
"tool": toolCall.Name,
"content_len": len(content),
"channel": opts.Channel,
})
@ -1290,24 +1378,32 @@ func (al *AgentLoop) runLLMIteration(
defer pubCancel()
_ = al.bus.PublishInbound(pubCtx, bus.InboundMessage{
Channel: "system",
SenderID: fmt.Sprintf("async:%s", tc.Name),
SenderID: fmt.Sprintf("async:%s", toolCall.Name),
ChatID: fmt.Sprintf("%s:%s", opts.Channel, opts.ChatID),
Content: content,
})
}
toolResult := agent.Tools.ExecuteWithContext(
ctx,
tc.Name,
tc.Arguments,
gCtx,
toolCall.Name,
toolCall.Arguments,
opts.Channel,
opts.ChatID,
asyncCallback,
)
agentResults[idx].result = toolResult
}(i, tc)
if toolResult.Err != nil {
health.DefaultMetrics.RecordCounter("tool_failure_"+toolCall.Name, 1)
} else {
health.DefaultMetrics.RecordCounter("tool_success_"+toolCall.Name, 1)
}
agentResults[idx].result = toolResult
return nil // We don't want a tool error to cancel other tools unless it's a panic/critical
})
}
if err := g.Wait(); err != nil {
return "", iteration, fmt.Errorf("tool execution phase failed: %w", err)
}
wg.Wait()
// Process results in original order (send to user, save to session)
for _, r := range agentResults {
@ -1962,3 +2058,45 @@ func isYesResponse(content string) bool {
c := strings.ToLower(strings.TrimSpace(content))
return c == "yes" || c == "y" || c == "ok" || c == "confirm" || c == "proceed"
}
func (al *AgentLoop) resolveMediaReferences(messages []providers.Message) []providers.Message {
if al.mediaStore == nil {
return messages
}
result := make([]providers.Message, len(messages))
copy(result, messages)
for i, msg := range result {
if len(msg.Media) == 0 {
continue
}
resolvedMedia := make([]string, 0, len(msg.Media))
for _, m := range msg.Media {
if !strings.HasPrefix(m, "media://") {
resolvedMedia = append(resolvedMedia, m)
continue
}
localPath, meta, err := al.mediaStore.ResolveWithMeta(m)
if err != nil {
logger.WarnCF("agent", "Failed to resolve media reference", map[string]any{"ref": m, "error": err.Error()})
continue
}
data, err := os.ReadFile(localPath)
if err != nil {
logger.WarnCF("agent", "Failed to read media file", map[string]any{"path": localPath, "error": err.Error()})
continue
}
base64Data := base64.StdEncoding.EncodeToString(data)
dataURI := fmt.Sprintf("data:%s;base64,%s", meta.ContentType, base64Data)
resolvedMedia = append(resolvedMedia, dataURI)
}
result[i].Media = resolvedMedia
}
return result
}

View file

@ -23,6 +23,7 @@ type MemoryStore struct {
workspace string
memoryDir string
memoryFile string
commsFile string
}
// NewMemoryStore creates a new MemoryStore with the given workspace path.
@ -30,6 +31,7 @@ type MemoryStore struct {
func NewMemoryStore(workspace string) *MemoryStore {
memoryDir := filepath.Join(workspace, "memory")
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
commsFile := filepath.Join(memoryDir, "COMMUNICATIONS.md")
// Ensure memory directory exists
os.MkdirAll(memoryDir, 0o755)
@ -38,6 +40,7 @@ func NewMemoryStore(workspace string) *MemoryStore {
workspace: workspace,
memoryDir: memoryDir,
memoryFile: memoryFile,
commsFile: commsFile,
}
}
@ -65,6 +68,38 @@ func (ms *MemoryStore) WriteLongTerm(content string) error {
return fileutil.WriteFileAtomic(ms.memoryFile, []byte(content), 0o600)
}
// ReadCommunications reads the communications memory (COMMUNICATIONS.md).
func (ms *MemoryStore) ReadCommunications() string {
if data, err := os.ReadFile(ms.commsFile); err == nil {
return string(data)
}
return ""
}
// WriteCommunications writes content to the communications memory file.
func (ms *MemoryStore) WriteCommunications(content string) error {
return fileutil.WriteFileAtomic(ms.commsFile, []byte(content), 0o600)
}
// AppendCommunications appends content to the communications memory file.
func (ms *MemoryStore) AppendCommunications(content string) error {
existing := ""
if data, err := os.ReadFile(ms.commsFile); err == nil {
existing = string(data)
}
trimmed := ""
if len(existing) > 0 {
// Keep only last 20KB to avoid excessive context
if len(existing) > 20000 {
existing = existing[len(existing)-20000:]
}
trimmed = existing + "\n\n---\n\n"
}
return fileutil.WriteFileAtomic(ms.commsFile, []byte(trimmed+content), 0o600)
}
// ReadToday reads today's daily note.
// Returns empty string if the file doesn't exist.
func (ms *MemoryStore) ReadToday() string {
@ -134,8 +169,9 @@ func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
func (ms *MemoryStore) GetMemoryContext() string {
longTerm := ms.ReadLongTerm()
recentNotes := ms.GetRecentDailyNotes(3)
comms := ms.ReadCommunications()
if longTerm == "" && recentNotes == "" {
if longTerm == "" && recentNotes == "" && comms == "" {
return ""
}
@ -146,8 +182,16 @@ func (ms *MemoryStore) GetMemoryContext() string {
sb.WriteString(longTerm)
}
if comms != "" {
if sb.Len() > 0 {
sb.WriteString("\n\n---\n\n")
}
sb.WriteString("## Recent Communications\n\n")
sb.WriteString(comms)
}
if recentNotes != "" {
if longTerm != "" {
if sb.Len() > 0 {
sb.WriteString("\n\n---\n\n")
}
sb.WriteString("## Recent Daily Notes\n\n")

69
pkg/agent/proactive.go Normal file
View file

@ -0,0 +1,69 @@
package agent
import (
"fmt"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/cron"
)
// ProactiveService manages automated background tasks for communication processing.
type ProactiveService struct {
cron *cron.CronService
cfg config.ProactiveConfig
}
// NewProactiveService creates a new ProactiveService.
func NewProactiveService(cron *cron.CronService, cfg config.ProactiveConfig) *ProactiveService {
return &ProactiveService{
cron: cron,
cfg: cfg,
}
}
// Start registers the automated jobs in the cron service.
func (s *ProactiveService) Start() error {
if !s.cfg.Enabled {
return nil
}
// 1. Job for syncing messaging history (WhatsApp/Gmail)
if s.cfg.SyncIntervalMinutes > 0 {
syncIntervalMS := int64(s.cfg.SyncIntervalMinutes) * 60 * 1000
// Note: We use a system-internal channel name to avoid cluttering user chat
_, err := s.cron.AddJob(
"Auto Sync Communications",
cron.CronSchedule{Kind: "every", EveryMS: &syncIntervalMS},
"Automatically sync my latest WhatsApp and Gmail communications to contextual memory.",
false, // Process via agent
"system",
"proactive_sync",
)
if err != nil {
return fmt.Errorf("failed to register auto-sync job: %w", err)
}
}
// 2. Job for processing insights and auto-updating Calendar/TODO
if s.cfg.ProcessIntervalMinutes > 0 {
processIntervalMS := int64(s.cfg.ProcessIntervalMinutes) * 60 * 1000
prompt := "PROACTIVE SYSTEM TURN: Analyze recent communications in COMMUNICATIONS.md. Identify any new calendar events or tasks. Update my Google Calendar and TODO list if necessary. Be silent if no actions are taken."
_, err := s.cron.AddJob(
"Proactive Action Extraction",
cron.CronSchedule{Kind: "every", EveryMS: &processIntervalMS},
prompt,
false, // Process via agent
"system",
"proactive_process",
)
if err != nil {
return fmt.Errorf("failed to register proactive processing job: %w", err)
}
}
return nil
}

View file

@ -1,6 +1,7 @@
package agent
import (
"sort"
"strings"
"github.com/sipeed/picoclaw/pkg/providers"
@ -33,42 +34,50 @@ func (al *AgentLoop) selectRelevantTools(agent *AgentInstance, userMsg string) [
"spawn": {"spawn", "acp", "harness", "agent", "protocol"},
}
relevant := make([]providers.ToolDefinition, 0)
type ScoredTool struct {
tool providers.ToolDefinition
score int
}
scored := make([]ScoredTool, 0, len(allTools))
for _, tool := range allTools {
name := tool.Function.Name
score := 0
// 1. Always include essential tools.
// 1. Always include essential tools (high score).
if essentialTools[name] {
relevant = append(relevant, tool)
continue
score += 100
}
// 2. Include based on keywords.
// 2. Score based on keywords.
if kws, ok := toolKeywords[name]; ok {
matched := false
for _, kw := range kws {
if strings.Contains(lowerMsg, kw) {
matched = true
break
score += 10
}
}
if matched {
relevant = append(relevant, tool)
continue
}
}
// 3. Fallback: if tool has no keywords defined, include it by default?
// To be safe and avoid context bloat, we only include tools with defined keywords if they match.
// If a tool is NOT in our map, we'll include it for now to avoid breaking unknown tools.
// 3. Fallback: if tool has no keywords defined, give it a base score to avoid starving unknown tools.
if _, ok := toolKeywords[name]; !ok {
relevant = append(relevant, tool)
score += 5
}
if score > 0 {
scored = append(scored, ScoredTool{tool: tool, score: score})
}
}
// Safety: ensure we don't return an empty tool set if any were available.
if len(relevant) == 0 && len(allTools) > 0 {
return allTools[:min(len(allTools), 5)]
// Sort by score descending
sort.Slice(scored, func(i, j int) bool {
return scored[i].score > scored[j].score
})
// Take Top-K (up to 12 tools for a good balance of capability and context)
topK := 12
relevant := make([]providers.ToolDefinition, 0, min(len(scored), topK))
for i := 0; i < min(len(scored), topK); i++ {
relevant = append(relevant, scored[i].tool)
}
return relevant

View file

@ -11,6 +11,12 @@ import (
// ErrBusClosed is returned when publishing to a closed MessageBus.
var ErrBusClosed = errors.New("message bus closed")
// InboundMiddleware is a function that can transform an inbound message.
type InboundMiddleware func(InboundMessage) InboundMessage
// OutboundMiddleware is a function that can transform an outbound message.
type OutboundMiddleware func(OutboundMessage) OutboundMessage
const defaultBusBufferSize = 64
type MessageBus struct {
@ -19,6 +25,9 @@ type MessageBus struct {
outboundMedia chan OutboundMediaMessage
done chan struct{}
closed atomic.Bool
inboundMiddleware []InboundMiddleware
outboundMiddleware []OutboundMiddleware
}
func NewMessageBus() *MessageBus {
@ -37,8 +46,15 @@ func (mb *MessageBus) PublishInbound(ctx context.Context, msg InboundMessage) er
if err := ctx.Err(); err != nil {
return err
}
// Apply middleware
processed := msg
for _, mw := range mb.inboundMiddleware {
processed = mw(processed)
}
select {
case mb.inbound <- msg:
case mb.inbound <- processed:
return nil
case <-mb.done:
return ErrBusClosed
@ -65,8 +81,15 @@ func (mb *MessageBus) PublishOutbound(ctx context.Context, msg OutboundMessage)
if err := ctx.Err(); err != nil {
return err
}
// Apply middleware
processed := msg
for _, mw := range mb.outboundMiddleware {
processed = mw(processed)
}
select {
case mb.outbound <- msg:
case mb.outbound <- processed:
return nil
case <-mb.done:
return ErrBusClosed
@ -114,6 +137,17 @@ func (mb *MessageBus) SubscribeOutboundMedia(ctx context.Context) (OutboundMedia
}
}
}
}
func (mb *MessageBus) RegisterInboundMiddleware(mw InboundMiddleware) {
mb.inboundMiddleware = append(mb.inboundMiddleware, mw)
}
func (mb *MessageBus) RegisterOutboundMiddleware(mw OutboundMiddleware) {
mb.outboundMiddleware = append(mb.outboundMiddleware, mw)
}
func (mb *MessageBus) Close() {
if mb.closed.CompareAndSwap(false, true) {
close(mb.done)

View file

@ -56,3 +56,8 @@ type CommandRegistrarCapable interface {
type QRProvider interface {
GetLastQR() string
}
// HistoryProvider is implemented by channels that can fetch message history.
type HistoryProvider interface {
FetchHistory(ctx context.Context, chatID string, limit int) ([]bus.InboundMessage, error)
}

View file

@ -340,9 +340,13 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) {
continue
}
mediaPaths = append(mediaPaths, storeMedia(localPath, file.Name))
if utils.IsAudioFile(file.Name, file.Mimetype) {
content += fmt.Sprintf("\n[audio: %s]", file.Name)
} else {
content += fmt.Sprintf("\n[file: %s]", file.Name)
}
}
}
if strings.TrimSpace(content) == "" {
return

View file

@ -54,8 +54,9 @@ func SplitMessage(content string, maxLen int) []string {
msgEnd = end
}
// Check if this would end with an incomplete code block
// Check if this would end with an incomplete code block or in the middle of a table
unclosedIdx := findLastUnclosedCodeBlockInRange(runes, start, msgEnd)
inTable := isInsideTableInRange(runes, start, msgEnd)
if unclosedIdx >= 0 {
// Message would end with incomplete code block
@ -126,6 +127,18 @@ func SplitMessage(content string, maxLen int) []string {
}
}
}
} else if inTable {
// Try to find the end of the table
tableEnd := findTableEndFrom(runes, msgEnd, totalLen)
if tableEnd > 0 && tableEnd-start <= maxLen {
msgEnd = tableEnd
} else {
// Table is too long, split before it if possible
tableStart := findTableStartBefore(runes, msgEnd, start)
if tableStart > start {
msgEnd = tableStart
}
}
}
if msgEnd <= start {
@ -223,3 +236,58 @@ func findLastDoubleNewlineInRange(runes []rune, start, end, searchWindow int) in
return start - 1
}
// isInsideTableInRange checks if the msgEnd point falls within a Markdown table.
func isInsideTableInRange(runes []rune, start, msgEnd int) bool {
// Simple heuristic: if the line at msgEnd and the line before it both start with |
lineStart := findLineStartBefore(runes, msgEnd)
if lineStart < start {
return false
}
return runes[lineStart] == '|'
}
func findLineStartBefore(runes []rune, idx int) int {
for i := idx - 1; i >= 0; i-- {
if runes[i] == '\n' {
return i + 1
}
}
return 0
}
func findTableEndFrom(runes []rune, from, totalLen int) int {
// Look for the first line that doesn't start with |
curr := from
for curr < totalLen {
eol := findNewlineFrom(runes, curr)
if eol == -1 {
eol = totalLen
}
// Skip leading whitespace to find the start of the next line
nextStart := eol
for nextStart < totalLen && (runes[nextStart] == '\n' || runes[nextStart] == '\r') {
nextStart++
}
if nextStart >= totalLen || runes[nextStart] != '|' {
return eol
}
curr = nextStart
}
return totalLen
}
func findTableStartBefore(runes []rune, before, start int) int {
// Look for the first line that doesn't start with | backwards
curr := before
for curr > start {
sol := findLineStartBefore(runes, curr)
if sol < start {
return start
}
if runes[sol] != '|' {
return curr // The newline before a table row
}
curr = sol - 1
}
return start
}

View file

@ -33,6 +33,7 @@ import (
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/identity"
"github.com/sipeed/picoclaw/pkg/logger"
"github.com/sipeed/picoclaw/pkg/media"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -356,12 +357,50 @@ func (c *WhatsAppNativeChannel) handleIncoming(evt *events.Message) {
}
content = utils.SanitizeMessageContent(content)
if content == "" {
return
}
var mediaPaths []string
// Handle media (Audio/Voice messages)
if evt.Message.AudioMessage != nil {
audio := evt.Message.AudioMessage
data, err := c.client.Download(audio)
if err != nil {
logger.WarnCF("whatsapp", "Failed to download audio", map[string]any{"error": err.Error()})
} else {
ext := ".ogg" // Default for WhatsApp voice notes
if audio.GetMimetype() == "audio/mp4" {
ext = ".m4a"
}
filename := fmt.Sprintf("audio-%s%s", evt.Info.ID, ext)
tempPath := filepath.Join(os.TempDir(), filename)
if err := os.WriteFile(tempPath, data, 0o644); err != nil {
logger.WarnCF("whatsapp", "Failed to save audio file", map[string]any{"error": err.Error()})
} else {
scope := channels.BuildMediaScope("whatsapp", chatID, evt.Info.ID)
if store := c.GetMediaStore(); store != nil {
ref, err := store.Store(tempPath, media.MediaMeta{
Filename: filename,
ContentType: audio.GetMimetype(),
Source: "whatsapp",
}, scope)
if err == nil {
mediaPaths = append(mediaPaths, ref)
if content != "" {
content += "\n"
}
// Mark for transcription in AgentLoop
if audio.GetPtt() {
content += "[voice]"
} else {
content += "[audio]"
}
} else {
mediaPaths = append(mediaPaths, tempPath)
}
}
}
}
}
metadata := make(map[string]string)
metadata["message_id"] = evt.Info.ID
if evt.Info.PushName != "" {
@ -456,3 +495,63 @@ func (c *WhatsAppNativeChannel) GetLastQR() string {
defer c.mu.Unlock()
return c.lastQR
}
func (c *WhatsAppNativeChannel) FetchHistory(ctx context.Context, chatID string, limit int) ([]bus.InboundMessage, error) {
if !c.IsRunning() {
return nil, channels.ErrNotRunning
}
jid, err := parseJID(chatID)
if err != nil {
return nil, fmt.Errorf("invalid chat id %q: %w", chatID, err)
}
c.mu.Lock()
client := c.client
c.mu.Unlock()
if client == nil {
return nil, fmt.Errorf("whatsapp client not initialized")
}
// Fetch messages from WhatsApp servers/local store
resp, err := client.FetchMessages(jid, limit, "", "")
if err != nil {
return nil, fmt.Errorf("whatsapp fetch history: %w", err)
}
messages := make([]bus.InboundMessage, 0, len(resp.Messages))
for _, m := range resp.Messages {
if m.Message == nil {
continue
}
content := m.Message.GetConversation()
if content == "" && m.Message.ExtendedTextMessage != nil {
content = m.Message.ExtendedTextMessage.GetText()
}
if content == "" {
continue
}
senderID := m.Info.Sender.String()
peerKind := "direct"
if m.Info.Chat.Server == types.GroupServer {
peerKind = "group"
}
messages = append(messages, bus.InboundMessage{
Channel: "whatsapp",
SenderID: senderID,
ChatID: m.Info.Chat.String(),
Content: content,
Peer: bus.Peer{
Kind: peerKind,
ID: m.Info.Chat.String(),
},
Timestamp: m.Info.Timestamp,
})
}
return messages, nil
}

View file

@ -1,23 +1,46 @@
package commands
// BuiltinDefinitions returns all built-in command definitions.
// Each command group is defined in its own cmd_*.go file.
// Definitions are stateless — runtime dependencies are provided
// via the Runtime parameter passed to handlers at execution time.
// Each command is a single-word command, compatible with Telegram's /command format.
func BuiltinDefinitions() []Definition {
return []Definition{
// Core
startCommand(),
helpCommand(),
versionCommand(),
pingCommand(),
clearCommand(),
// Info
modelCommand(), // /model [name] — show or switch model
modelsCommand(), // /models — list configured model
channelCommand(), // /channel [name] — show or check channel
channelsCommand(), // /channels — list enabled channels
agentsCommand(), // /agents — list registered agents
toolsCommand(), // /tools — list available tools
// Google / GWS
gloginCommand(), // /glogin [antigravity|gemini]
gstatusCommand(), // /gstatus
glogoutCommand(), // /glogout [antigravity|gemini|all]
gprojectCommand(), // /gproject <project-id>
gmailCommand(), // /gmail [list|search|unread|read]
driveCommand(), // /drive [list|search|docs|sheets]
docsCommand(), // /docs [create|open|list]
calCommand(), // /cal [today|week|month]
sheetsCommand(), // /sheets [list|search]
// Platform
qrCommand(), // /qr — WhatsApp QR code
vpsloginCommand(), // /vpslogin <password>
acpCommand(), // /acp <action>
// Legacy stubs (kept for backward compat, redirect users)
showCommand(),
listCommand(),
switchCommand(),
checkCommand(),
clearCommand(),
whatsappCommand(),
pingCommand(),
toolsCommand(),
modelCommand(),
vpsCommand(),
acpCommand(),
}
}

View file

@ -1,33 +1,16 @@
package commands
import (
"context"
"fmt"
)
import "context"
// checkCommand is kept as a no-op stub for backward compat.
// Use /channel <name> instead.
func checkCommand() Definition {
return Definition{
Name: "check",
Description: "Check channel availability",
SubCommands: []SubCommand{
{
Name: "channel",
Description: "Check if a channel is available",
ArgsUsage: "<name>",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.SwitchChannel == nil {
return req.Reply(unavailableMsg)
}
value := nthToken(req.Text, 2)
if value == "" {
return req.Reply("Usage: /check channel <name>")
}
if err := rt.SwitchChannel(value); err != nil {
return req.Reply(err.Error())
}
return req.Reply(fmt.Sprintf("Channel '%s' is available and enabled", value))
},
},
Description: "Alias: use /channel <name>",
Usage: "/check",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
return req.Reply("Use /channel <name> to check channel availability.")
},
}
}

181
pkg/commands/cmd_google.go Normal file
View file

@ -0,0 +1,181 @@
package commands
import (
"context"
"fmt"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
)
// gloginCommand starts a Google OAuth flow and saves the credential.
func gloginCommand() Definition {
return Definition{
Name: "glogin",
Description: "Authenticate with Google (GWS / Cloud). Opens browser for OAuth.",
Usage: "/glogin [antigravity|gemini]",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
provider := strings.ToLower(nthToken(req.Text, 1))
var cfg auth.OAuthProviderConfig
var providerKey string
switch provider {
case "gemini", "gcloud", "cloud":
cfg = auth.GeminiCLIOAuthConfig()
providerKey = "google-gemini"
default:
// Default: Antigravity / full GWS scopes (Gmail, Drive, Calendar, etc.)
cfg = auth.GoogleAntigravityOAuthConfig()
providerKey = "google-antigravity"
}
_ = req.Reply(fmt.Sprintf(
"🔐 Starting Google OAuth for *%s*...\nA browser window will open. Complete sign-in, then come back here.",
providerKey,
))
cred, err := auth.LoginBrowser(cfg)
if err != nil {
return req.Reply(fmt.Sprintf("❌ Google login failed: %v", err))
}
if err := auth.SetCredential(providerKey, cred); err != nil {
return req.Reply(fmt.Sprintf("❌ Failed to save credentials: %v", err))
}
email := cred.Email
if email == "" {
email = "(email not in token)"
}
return req.Reply(fmt.Sprintf("✅ Google login successful!\nProvider: %s\nAccount: %s", providerKey, email))
},
}
}
// gstatusCommand shows the current Google auth status.
func gstatusCommand() Definition {
return Definition{
Name: "gstatus",
Description: "Show current Google authentication status",
Usage: "/gstatus",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
providers := []string{"google-antigravity", "google-gemini"}
var sb strings.Builder
sb.WriteString("🔑 *Google Auth Status*\n\n")
anyFound := false
for _, p := range providers {
cred, err := auth.GetCredential(p)
if err != nil || cred == nil {
continue
}
anyFound = true
status := "✅ Active"
if cred.IsExpired() {
status = "⚠️ Expired"
} else if cred.NeedsRefresh() {
status = "🔄 Needs refresh soon"
}
sb.WriteString(fmt.Sprintf("*%s*\n", p))
sb.WriteString(fmt.Sprintf(" Status: %s\n", status))
if cred.Email != "" {
sb.WriteString(fmt.Sprintf(" Account: %s\n", cred.Email))
}
if cred.ProjectID != "" {
sb.WriteString(fmt.Sprintf(" Project: %s\n", cred.ProjectID))
}
if !cred.ExpiresAt.IsZero() {
remaining := time.Until(cred.ExpiresAt).Round(time.Minute)
sb.WriteString(fmt.Sprintf(" Expires in: %s\n", remaining))
}
sb.WriteString("\n")
}
if !anyFound {
sb.WriteString("No Google credentials found.\nUse /glogin to authenticate.")
}
return req.Reply(sb.String())
},
}
}
// glogoutCommand removes stored Google credentials.
func glogoutCommand() Definition {
return Definition{
Name: "glogout",
Description: "Remove stored Google credentials",
Usage: "/glogout [antigravity|gemini|all]",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
which := strings.ToLower(nthToken(req.Text, 1))
switch which {
case "gemini", "cloud":
if err := auth.DeleteCredential("google-gemini"); err != nil {
return req.Reply(fmt.Sprintf("❌ Failed to remove google-gemini credentials: %v", err))
}
return req.Reply("✅ Removed google-gemini credentials.")
case "all":
errs := []string{}
for _, p := range []string{"google-antigravity", "google-gemini"} {
if err := auth.DeleteCredential(p); err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", p, err))
}
}
if len(errs) > 0 {
return req.Reply("⚠️ Some removals failed:\n" + strings.Join(errs, "\n"))
}
return req.Reply("✅ All Google credentials removed.")
default:
// Default: antigravity
if err := auth.DeleteCredential("google-antigravity"); err != nil {
return req.Reply(fmt.Sprintf("❌ Failed to remove google-antigravity credentials: %v", err))
}
return req.Reply("✅ Removed google-antigravity credentials.")
}
},
}
}
// gprojectCommand sets the active GCP project ID on the stored credential.
func gprojectCommand() Definition {
return Definition{
Name: "gproject",
Description: "Set the active GCP project ID for Google cloud operations",
Usage: "/gproject <project-id>",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
projectID := nthToken(req.Text, 1)
if projectID == "" {
// Show current
cred, err := auth.GetCredential("google-antigravity")
if err != nil || cred == nil {
return req.Reply("No Google credentials found. Use /glogin first.")
}
if cred.ProjectID == "" {
return req.Reply("No GCP project set. Use: /gproject <project-id>")
}
return req.Reply(fmt.Sprintf("Current GCP project: `%s`", cred.ProjectID))
}
cred, err := auth.GetCredential("google-antigravity")
if err != nil {
return req.Reply(fmt.Sprintf("❌ Failed to load credentials: %v", err))
}
if cred == nil {
return req.Reply("No Google credentials found. Use /glogin first.")
}
cred.ProjectID = projectID
if err := auth.SetCredential("google-antigravity", cred); err != nil {
return req.Reply(fmt.Sprintf("❌ Failed to save project: %v", err))
}
return req.Reply(fmt.Sprintf("✅ GCP project set to: `%s`", projectID))
},
}
}

372
pkg/commands/cmd_gws.go Normal file
View file

@ -0,0 +1,372 @@
package commands
import (
"context"
"fmt"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/gws"
)
// ── /gmail ───────────────────────────────────────────────────────────────────
func gmailCommand() Definition {
return Definition{
Name: "gmail",
Description: "Gmail: list, search, or read emails",
Usage: "/gmail [list|search <query>|read <id>|help]",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
action := strings.ToLower(nthToken(req.Text, 1))
if action == "" || action == "help" {
return req.Reply(
"📧 *Gmail Commands*\n\n" +
"• `/gmail list` — last 10 inbox messages\n" +
"• `/gmail search <query>` — search by subject/sender/content\n" +
"• `/gmail unread` — unread messages only\n" +
"• `/gmail read <message-id>` — open a specific message\n",
)
}
c, err := gws.New()
if err != nil {
return req.Reply("❌ " + err.Error())
}
switch action {
case "list":
msgs, err := c.GmailList("in:inbox", 10)
if err != nil {
return req.Reply("❌ Gmail list failed: " + err.Error())
}
return req.Reply(formatGmailList(msgs, "Inbox"))
case "unread":
msgs, err := c.GmailList("is:unread in:inbox", 10)
if err != nil {
return req.Reply("❌ Gmail unread failed: " + err.Error())
}
return req.Reply(formatGmailList(msgs, "Unread"))
case "search":
query := strings.Join(tailTokens(req.Text, 2), " ")
if query == "" {
return req.Reply("Usage: /gmail search <query>")
}
msgs, err := c.GmailList(query, 10)
if err != nil {
return req.Reply("❌ Gmail search failed: " + err.Error())
}
return req.Reply(formatGmailList(msgs, "Search: "+query))
case "read":
msgID := nthToken(req.Text, 2)
if msgID == "" {
return req.Reply("Usage: /gmail read <message-id>")
}
msg, err := c.GmailRead(msgID)
if err != nil {
return req.Reply("❌ Gmail read failed: " + err.Error())
}
from := gws.HeaderValue(*msg, "From")
subject := gws.HeaderValue(*msg, "Subject")
date := gws.HeaderValue(*msg, "Date")
return req.Reply(fmt.Sprintf(
"📧 *%s*\nFrom: %s\nDate: %s\n\n%s",
subject, from, date, truncateStr(msg.Snippet, 500),
))
default:
return req.Reply(fmt.Sprintf("Unknown gmail action: %s\nUse /gmail help.", action))
}
},
}
}
func formatGmailList(msgs []gws.GmailMessage, title string) string {
if len(msgs) == 0 {
return fmt.Sprintf("📧 *%s*\n\nNo messages found.", title)
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📧 *%s* (%d)\n\n", title, len(msgs)))
for _, m := range msgs {
subject := gws.HeaderValue(m, "Subject")
from := gws.HeaderValue(m, "From")
if subject == "" {
subject = "(no subject)"
}
sb.WriteString(fmt.Sprintf("• [%s] %s\n `%s`\n", from, subject, m.ID))
}
return sb.String()
}
// ── /drive ───────────────────────────────────────────────────────────────────
func driveCommand() Definition {
return Definition{
Name: "drive",
Description: "Google Drive: list or search files",
Usage: "/drive [list|search <query>|docs|sheets|help]",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
action := strings.ToLower(nthToken(req.Text, 1))
if action == "" || action == "help" {
return req.Reply(
"💾 *Drive Commands*\n\n" +
"• `/drive list` — recent files\n" +
"• `/drive search <query>` — search by name\n" +
"• `/drive docs` — recent Google Docs\n" +
"• `/drive sheets` — recent Sheets\n",
)
}
c, err := gws.New()
if err != nil {
return req.Reply("❌ " + err.Error())
}
switch action {
case "list":
files, err := c.DriveList("", "", 10)
if err != nil {
return req.Reply("❌ Drive list failed: " + err.Error())
}
return req.Reply(formatDriveList(files, "Recent Files"))
case "search":
query := strings.Join(tailTokens(req.Text, 2), " ")
if query == "" {
return req.Reply("Usage: /drive search <query>")
}
files, err := c.DriveList(query, "", 10)
if err != nil {
return req.Reply("❌ Drive search failed: " + err.Error())
}
return req.Reply(formatDriveList(files, "Search: "+query))
case "docs":
files, err := c.DriveList("", "application/vnd.google-apps.document", 10)
if err != nil {
return req.Reply("❌ Drive docs failed: " + err.Error())
}
return req.Reply(formatDriveList(files, "Recent Docs"))
case "sheets":
files, err := c.DriveList("", "application/vnd.google-apps.spreadsheet", 10)
if err != nil {
return req.Reply("❌ Drive sheets failed: " + err.Error())
}
return req.Reply(formatDriveList(files, "Recent Sheets"))
default:
return req.Reply(fmt.Sprintf("Unknown drive action: %s\nUse /drive help.", action))
}
},
}
}
func formatDriveList(files []gws.DriveFile, title string) string {
if len(files) == 0 {
return fmt.Sprintf("💾 *%s*\n\nNo files found.", title)
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("💾 *%s* (%d)\n\n", title, len(files)))
for _, f := range files {
label := gws.MimeTypeLabel(f.MimeType)
sb.WriteString(fmt.Sprintf("• [%s] %s\n `%s`\n", label, f.Name, f.ID))
}
return sb.String()
}
// ── /docs ────────────────────────────────────────────────────────────────────
func docsCommand() Definition {
return Definition{
Name: "docs",
Description: "Google Docs: create or open a document",
Usage: "/docs [create <title>|open <id>|help]",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
action := strings.ToLower(nthToken(req.Text, 1))
if action == "" || action == "help" {
return req.Reply(
"📄 *Docs Commands*\n\n" +
"• `/docs create <title>` — create a new Google Doc\n" +
"• `/docs open <document-id>` — get document info\n" +
"• `/docs list` — recent docs (via Drive)\n",
)
}
c, err := gws.New()
if err != nil {
return req.Reply("❌ " + err.Error())
}
switch action {
case "create":
title := strings.Join(tailTokens(req.Text, 2), " ")
if title == "" {
title = fmt.Sprintf("Document %s", time.Now().Format("2006-01-02"))
}
doc, err := c.DocsCreate(title)
if err != nil {
return req.Reply("❌ Docs create failed: " + err.Error())
}
return req.Reply(fmt.Sprintf(
"📄 *Document created!*\nTitle: %s\nID: `%s`\nOpen: https://docs.google.com/document/d/%s/edit",
doc.Title, doc.DocumentID, doc.DocumentID,
))
case "open":
docID := nthToken(req.Text, 2)
if docID == "" {
return req.Reply("Usage: /docs open <document-id>")
}
doc, err := c.DocsGet(docID)
if err != nil {
return req.Reply("❌ Docs open failed: " + err.Error())
}
return req.Reply(fmt.Sprintf(
"📄 *%s*\nID: `%s`\nRevision: %s\nOpen: https://docs.google.com/document/d/%s/edit",
doc.Title, doc.DocumentID, doc.RevisionID, doc.DocumentID,
))
case "list":
files, err := c.DriveList("", "application/vnd.google-apps.document", 10)
if err != nil {
return req.Reply("❌ Docs list failed: " + err.Error())
}
return req.Reply(formatDriveList(files, "Recent Docs"))
default:
return req.Reply(fmt.Sprintf("Unknown docs action: %s\nUse /docs help.", action))
}
},
}
}
// ── /cal ─────────────────────────────────────────────────────────────────────
func calCommand() Definition {
return Definition{
Name: "cal",
Description: "Google Calendar: list upcoming events",
Usage: "/cal [today|week|month|help]",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
action := strings.ToLower(nthToken(req.Text, 1))
if action == "help" {
return req.Reply(
"📅 *Calendar Commands*\n\n" +
"• `/cal` or `/cal today` — events today\n" +
"• `/cal week` — next 7 days\n" +
"• `/cal month` — next 30 days\n",
)
}
c, err := gws.New()
if err != nil {
return req.Reply("❌ " + err.Error())
}
now := time.Now()
var timeMax time.Time
var label string
switch action {
case "week":
timeMax = now.Add(7 * 24 * time.Hour)
label = "Next 7 Days"
case "month":
timeMax = now.Add(30 * 24 * time.Hour)
label = "Next 30 Days"
default: // today or empty
timeMax = time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location())
label = "Today"
}
events, err := c.CalendarList(now, timeMax, 15)
if err != nil {
return req.Reply("❌ Calendar failed: " + err.Error())
}
if len(events) == 0 {
return req.Reply(fmt.Sprintf("📅 *%s* — No events.", label))
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("📅 *%s* (%d events)\n\n", label, len(events)))
for _, ev := range events {
t := gws.FormatEventTime(ev)
sb.WriteString(fmt.Sprintf("• %s — %s\n", t, ev.Summary))
}
return req.Reply(sb.String())
},
}
}
// ── /sheets ──────────────────────────────────────────────────────────────────
func sheetsCommand() Definition {
return Definition{
Name: "sheets",
Description: "Google Sheets: list or search spreadsheets",
Usage: "/sheets [list|search <query>|help]",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
action := strings.ToLower(nthToken(req.Text, 1))
if action == "help" {
return req.Reply(
"📊 *Sheets Commands*\n\n" +
"• `/sheets list` — recent spreadsheets\n" +
"• `/sheets search <query>` — search by name\n",
)
}
c, err := gws.New()
if err != nil {
return req.Reply("❌ " + err.Error())
}
const sheetMime = "application/vnd.google-apps.spreadsheet"
switch action {
case "search":
query := strings.Join(tailTokens(req.Text, 2), " ")
if query == "" {
return req.Reply("Usage: /sheets search <query>")
}
files, err := c.DriveList(query, sheetMime, 10)
if err != nil {
return req.Reply("❌ Sheets search failed: " + err.Error())
}
return req.Reply(formatDriveList(files, "Sheets: "+query))
default: // list
files, err := c.DriveList("", sheetMime, 10)
if err != nil {
return req.Reply("❌ Sheets list failed: " + err.Error())
}
return req.Reply(formatDriveList(files, "Recent Sheets"))
}
},
}
}
// ── Helpers ──────────────────────────────────────────────────────────────────
// tailTokens returns all tokens starting at index n (0-indexed), joined as-is.
func tailTokens(text string, n int) []string {
parts := strings.Fields(strings.TrimSpace(text))
if n >= len(parts) {
return nil
}
return parts[n:]
}
func truncateStr(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}

View file

@ -2,6 +2,9 @@ package commands
import (
"context"
"fmt"
"sort"
"strings"
)
func helpCommand() Definition {
@ -21,42 +24,28 @@ func helpCommand() Definition {
}
}
func formatHelpMessage(_ []Definition) string {
return `System:
/help Show this help
/model [name] Show or switch the active model
/version Show version info
/tools List available tools
/debug Toggle debug mode
/ping Connectivity check
/vps login <pw> Set VPS password securely
/whatsapp qr Get WhatsApp pairing QR code
/acp <cmd> <args> Manage active ACP harness sessions
func formatHelpMessage(defs []Definition) string {
sort.Slice(defs, func(i, j int) bool {
return defs[i].Name < defs[j].Name
})
Jobs:
/job <desc> Create a new job
/status <id> Check job status
/cancel <id> Cancel a job
/list List all jobs
var sb strings.Builder
sb.WriteString("🦞 *PicoClaw Commands*\n\n")
Session:
/undo Undo last turn
/redo Redo undone turn
/compact Compress context window
/clear Clear current thread
/interrupt Stop current operation
/new New conversation thread
/thread <id> Switch to thread
/resume <id> Resume from checkpoint
for _, d := range defs {
// Skip legacy stub commands
if strings.HasPrefix(d.Description, "Alias:") {
continue
}
Skills:
/skills List installed skills
/skills search <q> Search ClawHub registry
usage := d.Usage
if usage == "" {
usage = "/" + d.Name
}
sb.WriteString(fmt.Sprintf("• `%s` — %s\n", usage, d.Description))
}
Agent:
/heartbeat Run heartbeat check
/summarize Summarize current thread
/suggest Suggest next steps
/quit Exit`
sb.WriteString("\n• `<shell command>` — Run a shell command (if exec tool enabled)\n")
sb.WriteString("\nTip: All commands are single-word, e.g. `/model gpt-4o`")
return sb.String()
}

View file

@ -6,14 +6,24 @@ import (
"strings"
)
// listCommand is kept as a no-op stub for backward compat.
// All functionality moved to /models and /channels.
func listCommand() Definition {
return Definition{
Name: "list",
Description: "List available options",
SubCommands: []SubCommand{
{
Description: "Alias: use /models or /channels",
Usage: "/list",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
return req.Reply("Use /models or /channels instead.")
},
}
}
func modelsCommand() Definition {
return Definition{
Name: "models",
Description: "Configured models",
Description: "Show the currently configured model",
Usage: "/models",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.GetModelInfo == nil {
return req.Reply(unavailableMsg)
@ -22,31 +32,25 @@ func listCommand() Definition {
if provider == "" {
provider = "configured default"
}
return req.Reply(fmt.Sprintf(
"Configured Model: %s\nProvider: %s\n\nTo change models, update config.json",
name, provider,
))
return req.Reply(fmt.Sprintf("Model: %s\nProvider: %s", name, provider))
},
},
{
}
}
func channelsCommand() Definition {
return Definition{
Name: "channels",
Description: "Enabled channels",
Description: "List enabled channels",
Usage: "/channels",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.GetEnabledChannels == nil {
return req.Reply(unavailableMsg)
}
enabled := rt.GetEnabledChannels()
if len(enabled) == 0 {
return req.Reply("No channels enabled")
return req.Reply("No channels enabled.")
}
return req.Reply(fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")))
},
},
{
Name: "agents",
Description: "Registered agents",
Handler: agentsHandler(),
},
return req.Reply("Enabled channels:\n- " + strings.Join(enabled, "\n- "))
},
}
}

View file

@ -5,34 +5,49 @@ import (
"fmt"
)
// showCommand is kept as a no-op stub for backward compat.
// All functionality moved to /model, /channel, /agents.
func showCommand() Definition {
return Definition{
Name: "show",
Description: "Show current configuration",
SubCommands: []SubCommand{
{
Name: "model",
Description: "Current model and provider",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.GetModelInfo == nil {
return req.Reply(unavailableMsg)
}
name, provider := rt.GetModelInfo()
return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider))
},
},
{
Name: "channel",
Description: "Current channel",
Description: "Alias: use /model, /channel, or /agents",
Usage: "/show",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel))
},
},
{
Name: "agents",
Description: "Registered agents",
Handler: agentsHandler(),
},
return req.Reply("Use /model, /channel, or /agents instead.")
},
}
}
func channelCommand() Definition {
return Definition{
Name: "channel",
Description: "Show current channel or check a named channel",
Usage: "/channel [name]",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
name := nthToken(req.Text, 1)
// No arg: show current channel
if name == "" {
return req.Reply(fmt.Sprintf("Current channel: %s", req.Channel))
}
// With arg: check/switch channel
if rt == nil || rt.SwitchChannel == nil {
return req.Reply(unavailableMsg)
}
if err := rt.SwitchChannel(name); err != nil {
return req.Reply(fmt.Sprintf("Channel '%s' is not available: %v", name, err))
}
return req.Reply(fmt.Sprintf("Channel '%s' is available and enabled.", name))
},
}
}
func agentsCommand() Definition {
return Definition{
Name: "agents",
Description: "List registered agents",
Usage: "/agents",
Handler: agentsHandler(),
}
}

View file

@ -1,42 +1,16 @@
package commands
import (
"context"
"fmt"
)
import "context"
// switchCommand is kept as a no-op stub for backward compat.
// Use /model <name> to switch models.
func switchCommand() Definition {
return Definition{
Name: "switch",
Description: "Switch model",
SubCommands: []SubCommand{
{
Name: "model",
Description: "Switch to a different model",
ArgsUsage: "to <name>",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.SwitchModel == nil {
return req.Reply(unavailableMsg)
}
// Parse: /switch model to <value>
value := nthToken(req.Text, 3) // tokens: [/switch, model, to, <value>]
if nthToken(req.Text, 2) != "to" || value == "" {
return req.Reply("Usage: /switch model to <name>")
}
oldModel, err := rt.SwitchModel(value)
if err != nil {
return req.Reply(err.Error())
}
return req.Reply(fmt.Sprintf("Switched model from %s to %s", oldModel, value))
},
},
{
Name: "channel",
Description: "Moved to /check channel",
Description: "Alias: use /model <name> to switch",
Usage: "/switch",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
return req.Reply("This command has moved. Please use: /check channel <name>")
},
},
return req.Reply("Use /model <name> to switch models.")
},
}
}

View file

@ -84,17 +84,23 @@ func modelCommand() Definition {
func vpsCommand() Definition {
return Definition{
Name: "vps",
Description: "Configure VPS credentials",
Usage: "/vps login <password>",
SubCommands: []SubCommand{
{
Name: "login",
Description: "Set VPS password securely",
ArgsUsage: "<password>",
Description: "Alias: use /vpslogin <password>",
Usage: "/vps",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
password := nthToken(req.Text, 2)
return req.Reply("Use /vpslogin <password> to set VPS credentials.")
},
}
}
func vpsloginCommand() Definition {
return Definition{
Name: "vpslogin",
Description: "Set VPS password securely",
Usage: "/vpslogin <password>",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
password := nthToken(req.Text, 1)
if password == "" {
return req.Reply("Usage: /vps login <password>")
return req.Reply("Usage: /vpslogin <password>")
}
cred := &auth.AuthCredential{
AccessToken: password,
@ -106,7 +112,5 @@ func vpsCommand() Definition {
}
return req.Reply("VPS credentials saved securely.")
},
},
},
}
}

View file

@ -5,37 +5,44 @@ import (
"fmt"
)
type qrProvider interface {
GetLastQR() string
}
func whatsappCommand() Definition {
func qrCommand() Definition {
return Definition{
Name: "whatsapp",
Description: "WhatsApp management commands",
SubCommands: []SubCommand{
{
Name: "qr",
Description: "Get the latest WhatsApp pairing QR code",
Description: "Get the WhatsApp pairing QR code",
Usage: "/qr",
Handler: func(ctx context.Context, req Request, rt *Runtime) error {
ch, ok := rt.GetChannel("whatsapp_native")
if !ok {
return req.Reply("whatsapp_native channel is not enabled")
return req.Reply("whatsapp_native channel is not enabled.")
}
type qrProvider interface {
GetLastQR() string
}
qp, ok := ch.(qrProvider)
if !ok {
return req.Reply("whatsapp_native channel does not support QR retrieval")
return req.Reply("whatsapp_native channel does not support QR retrieval.")
}
qr := qp.GetLastQR()
if qr == "" {
return req.Reply("no QR code available yet. please wait for the channel to initialize")
return req.Reply("No QR code available yet. Wait for the channel to initialize.")
}
return req.Reply(fmt.Sprintf("Scan this QR code string (or wait for image support): %s", qr))
},
},
return req.Reply(fmt.Sprintf("Scan this QR code: %s", qr))
},
}
}
// whatsappCommand kept as a stub for backward compat.
func whatsappCommand() Definition {
return Definition{
Name: "whatsapp",
Description: "Alias: use /qr",
Usage: "/whatsapp",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
return req.Reply("Use /qr to get the WhatsApp pairing QR code.")
},
}
}

View file

@ -7,6 +7,7 @@ import (
"sync/atomic"
"github.com/caarlos0/env/v11"
"github.com/go-playground/validator/v10"
"github.com/sipeed/picoclaw/pkg/fileutil"
)
@ -130,11 +131,11 @@ func (m AgentModelConfig) MarshalJSON() ([]byte, error) {
}
type AgentConfig struct {
ID string `json:"id"`
ID string `json:"id" validate:"required"`
Default bool `json:"default,omitempty"`
Name string `json:"name,omitempty"`
Workspace string `json:"workspace,omitempty"`
Model *AgentModelConfig `json:"model,omitempty"`
Model *AgentModelConfig `json:"model,omitempty" validate:"required"`
Skills []string `json:"skills,omitempty"`
Subagents *SubagentsConfig `json:"subagents,omitempty"`
}
@ -189,9 +190,9 @@ type AgentDefaults struct {
ModelFallbacks []string `json:"model_fallbacks,omitempty"`
ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"`
ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS" validate:"gt=0"`
Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE" validate:"omitempty,gte=0,lte=2"`
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS" validate:"gte=1,lte=100"`
SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"`
SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"`
MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"`
@ -603,6 +604,18 @@ type TavilyConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_TAVILY_MAX_RESULTS"`
}
type ElevenLabsConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_VOICE_ELEVENLABS_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_VOICE_ELEVENLABS_API_KEY"`
VoiceID string `json:"voice_id" env:"PICOCLAW_TOOLS_VOICE_ELEVENLABS_VOICE_ID"`
}
type InteractionConfig struct {
WritingStyle string `json:"writing_style" env:"PICOCLAW_INTERACTION_WRITING_STYLE"`
AutoReplyEnabled bool `json:"autoreply_enabled" env:"PICOCLAW_INTERACTION_AUTOREPLY_ENABLED"`
ApprovalRequired bool `json:"approval_required" env:"PICOCLAW_INTERACTION_APPROVAL_REQUIRED"`
}
type DuckDuckGoConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"`
@ -676,9 +689,10 @@ type MediaCleanupConfig struct {
Interval int ` env:"PICOCLAW_MEDIA_CLEANUP_INTERVAL" json:"interval_minutes"`
}
type ReadFileToolConfig struct {
Enabled bool `json:"enabled"`
MaxReadFileSize int `json:"max_read_file_size"`
type ProactiveConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_PROACTIVE_ENABLED"`
SyncIntervalMinutes int `json:"sync_interval_minutes" env:"PICOCLAW_PROACTIVE_SYNC_INTERVAL"`
ProcessIntervalMinutes int `json:"process_interval_minutes" env:"PICOCLAW_PROACTIVE_PROCESS_INTERVAL"`
}
type ToolsConfig struct {
@ -694,11 +708,14 @@ type ToolsConfig struct {
EditFile ToolConfig `json:"edit_file" envPrefix:"PICOCLAW_TOOLS_EDIT_FILE_"`
FindSkills ToolConfig `json:"find_skills" envPrefix:"PICOCLAW_TOOLS_FIND_SKILLS_"`
I2C ToolConfig `json:"i2c" envPrefix:"PICOCLAW_TOOLS_I2C_"`
Browser ToolConfig `json:"browser" envPrefix:"PICOCLAW_TOOLS_BROWSER_"`
Image ToolConfig `json:"image" envPrefix:"PICOCLAW_TOOLS_IMAGE_"`
InstallSkill ToolConfig `json:"install_skill" envPrefix:"PICOCLAW_TOOLS_INSTALL_SKILL_"`
Google ToolConfig `json:"google" envPrefix:"PICOCLAW_TOOLS_GOOGLE_"`
ListDir ToolConfig `json:"list_dir" envPrefix:"PICOCLAW_TOOLS_LIST_DIR_"`
Message ToolConfig `json:"message" envPrefix:"PICOCLAW_TOOLS_MESSAGE_"`
ReadFile ReadFileToolConfig `json:"read_file" envPrefix:"PICOCLAW_TOOLS_READ_FILE_"`
PDF ToolConfig `json:"pdf" envPrefix:"PICOCLAW_TOOLS_PDF_"`
SendFile ToolConfig `json:"send_file" envPrefix:"PICOCLAW_TOOLS_SEND_FILE_"`
Spawn ToolConfig `json:"spawn" envPrefix:"PICOCLAW_TOOLS_SPAWN_"`
SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"`
@ -707,6 +724,9 @@ type ToolsConfig struct {
VPS VPSConfig `json:"vps" envPrefix:"PICOCLAW_TOOLS_VPS_"`
VoiceCall ToolConfig `json:"voice_call" envPrefix:"PICOCLAW_TOOLS_VOICECALL_"`
WriteFile ToolConfig `json:"write_file" envPrefix:"PICOCLAW_TOOLS_WRITE_FILE_"`
Proactive ProactiveConfig `json:"proactive"`
ElevenLabs ElevenLabsConfig `json:"elevenlabs"`
Interaction InteractionConfig `json:"interaction"`
}
type SearchCacheConfig struct {
@ -791,6 +811,11 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
validate := validator.New()
if err := validate.Struct(cfg); err != nil {
return nil, fmt.Errorf("config validation failed: %w", err)
}
// Migrate legacy channel config fields to new unified structures
cfg.migrateChannelConfigs()
@ -960,6 +985,10 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.FindSkills.Enabled
case "i2c":
return t.I2C.Enabled
case "browser":
return t.Browser.Enabled
case "image":
return t.Image.Enabled
case "install_skill":
return t.InstallSkill.Enabled
case "google":
@ -972,6 +1001,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool {
return t.Message.Enabled
case "read_file":
return t.ReadFile.Enabled
case "pdf":
return t.PDF.Enabled
case "spawn":
return t.Spawn.Enabled
case "spi":

View file

@ -497,6 +497,15 @@ func DefaultConfig() *Config {
Message: ToolConfig{
Enabled: true,
},
PDF: ToolConfig{
Enabled: true,
},
Browser: ToolConfig{
Enabled: false, // Requires Chrome/Chromium installed
},
Image: ToolConfig{
Enabled: true,
},
ReadFile: ReadFileToolConfig{
Enabled: true,
MaxReadFileSize: 64 * 1024, // 64KB
@ -521,6 +530,19 @@ func DefaultConfig() *Config {
WriteFile: ToolConfig{
Enabled: true,
},
Proactive: ProactiveConfig{
Enabled: true,
SyncIntervalMinutes: 60,
ProcessIntervalMinutes: 120,
},
ElevenLabs: ElevenLabsConfig{
Enabled: false,
},
Interaction: InteractionConfig{
WritingStyle: "Casual and helpful",
AutoReplyEnabled: false,
ApprovalRequired: true,
},
},
Heartbeat: HeartbeatConfig{
Enabled: true,

View file

@ -14,10 +14,31 @@ import (
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
// Otherwise, the protocol prefix is added.
func buildModelWithProtocol(protocol, model string) string {
if model == "" {
return ""
}
// If the model already starts with the protocol prefix, return as-is
prefix := protocol + "/"
if strings.HasPrefix(model, prefix) {
return model
}
// Handle known nested prefixes for specific providers
// Example: provider 'nvidia' and model 'meta/llama-3.1' -> 'nvidia/meta/llama-3.1'
if protocol == "nvidia" && (strings.HasPrefix(model, "meta/") || strings.HasPrefix(model, "nvidia/")) {
if strings.HasPrefix(model, "nvidia/") {
return model // Already correctly prefixed
}
return prefix + model
}
// If the model already has some other protocol prefix (contains "/"),
// we assume it's intentional and return it as-is.
if strings.Contains(model, "/") {
return model
}
return prefix + model
}

327
pkg/gws/client.go Normal file
View file

@ -0,0 +1,327 @@
// Package gws provides a lightweight Google Workspace REST client.
// It uses stored OAuth credentials (google-antigravity) and makes
// direct HTTP calls to Google APIs without requiring the full Google SDK.
package gws
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/auth"
)
const (
gmailBase = "https://gmail.googleapis.com/gmail/v1/users/me"
driveBase = "https://www.googleapis.com/drive/v3"
calBase = "https://www.googleapis.com/calendar/v3"
docsBase = "https://docs.googleapis.com/v1"
sheetsBase = "https://sheets.googleapis.com/v4"
providerKey = "google-antigravity"
)
// Client is a thin GWS REST client backed by a stored OAuth token.
type Client struct {
http *http.Client
token string
}
// New creates a GWS client from stored credentials. Returns an error if not authenticated.
func New() (*Client, error) {
cred, err := auth.GetCredential(providerKey)
if err != nil {
return nil, fmt.Errorf("failed to load Google credentials: %w", err)
}
if cred == nil {
return nil, fmt.Errorf("not authenticated. Use /glogin first")
}
if cred.IsExpired() {
// Attempt token refresh
cfg := auth.GoogleAntigravityOAuthConfig()
refreshed, err := auth.RefreshAccessToken(cred, cfg)
if err != nil {
return nil, fmt.Errorf("token expired and refresh failed: %w. Use /glogin to re-authenticate", err)
}
if err := auth.SetCredential(providerKey, refreshed); err != nil {
return nil, fmt.Errorf("failed to save refreshed token: %w", err)
}
cred = refreshed
}
return &Client{
http: &http.Client{Timeout: 15 * time.Second},
token: cred.AccessToken,
}, nil
}
// get performs an authenticated GET request and decodes JSON into dest.
func (c *Client) get(rawURL string, dest any) error {
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("API error %d: %s", resp.StatusCode, truncate(string(body), 200))
}
if dest != nil {
if err := json.Unmarshal(body, dest); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
}
return nil
}
// post performs an authenticated POST request with a JSON body.
func (c *Client) post(rawURL string, body any, dest any) error {
b, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest("POST", rawURL, strings.NewReader(string(b)))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return fmt.Errorf("API error %d: %s", resp.StatusCode, truncate(string(respBody), 200))
}
if dest != nil {
if err := json.Unmarshal(respBody, dest); err != nil {
return fmt.Errorf("failed to parse response: %w", err)
}
}
return nil
}
// ── Gmail ────────────────────────────────────────────────────────────────────
type GmailMessage struct {
ID string `json:"id"`
Snippet string `json:"snippet"`
Payload struct {
Headers []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"headers"`
} `json:"payload"`
}
type GmailListResponse struct {
Messages []struct{ ID string `json:"id"` } `json:"messages"`
ResultSizeEstimate int `json:"resultSizeEstimate"`
}
func (c *Client) GmailList(query string, maxResults int) ([]GmailMessage, error) {
if maxResults <= 0 {
maxResults = 10
}
params := url.Values{
"maxResults": {fmt.Sprintf("%d", maxResults)},
}
if query != "" {
params.Set("q", query)
}
var listResp GmailListResponse
if err := c.get(gmailBase+"/messages?"+params.Encode(), &listResp); err != nil {
return nil, err
}
var messages []GmailMessage
for i, m := range listResp.Messages {
if i >= maxResults {
break
}
var msg GmailMessage
if err := c.get(fmt.Sprintf("%s/messages/%s?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date", gmailBase, m.ID), &msg); err != nil {
continue
}
messages = append(messages, msg)
}
return messages, nil
}
func (c *Client) GmailRead(msgID string) (*GmailMessage, error) {
var msg GmailMessage
if err := c.get(fmt.Sprintf("%s/messages/%s?format=full", gmailBase, msgID), &msg); err != nil {
return nil, err
}
return &msg, nil
}
// ── Drive ────────────────────────────────────────────────────────────────────
type DriveFile struct {
ID string `json:"id"`
Name string `json:"name"`
MimeType string `json:"mimeType"`
ModifiedTime string `json:"modifiedTime"`
WebViewLink string `json:"webViewLink"`
}
type DriveListResponse struct {
Files []DriveFile `json:"files"`
NextPageToken string `json:"nextPageToken"`
}
func (c *Client) DriveList(query string, mimeFilter string, maxResults int) ([]DriveFile, error) {
if maxResults <= 0 {
maxResults = 10
}
q := "trashed=false"
if query != "" {
q += fmt.Sprintf(" and name contains '%s'", strings.ReplaceAll(query, "'", "\\'"))
}
if mimeFilter != "" {
q += fmt.Sprintf(" and mimeType='%s'", mimeFilter)
}
params := url.Values{
"q": {q},
"pageSize": {fmt.Sprintf("%d", maxResults)},
"fields": {"files(id,name,mimeType,modifiedTime,webViewLink)"},
"orderBy": {"modifiedTime desc"},
}
var resp DriveListResponse
if err := c.get(driveBase+"/files?"+params.Encode(), &resp); err != nil {
return nil, err
}
return resp.Files, nil
}
// ── Calendar ─────────────────────────────────────────────────────────────────
type CalendarEvent struct {
ID string `json:"id"`
Summary string `json:"summary"`
Start struct {
DateTime string `json:"dateTime"`
Date string `json:"date"`
} `json:"start"`
End struct {
DateTime string `json:"dateTime"`
Date string `json:"date"`
} `json:"end"`
HtmlLink string `json:"htmlLink"`
Description string `json:"description"`
}
type CalendarListResponse struct {
Items []CalendarEvent `json:"items"`
}
func (c *Client) CalendarList(timeMin, timeMax time.Time, maxResults int) ([]CalendarEvent, error) {
if maxResults <= 0 {
maxResults = 10
}
params := url.Values{
"timeMin": {timeMin.UTC().Format(time.RFC3339)},
"timeMax": {timeMax.UTC().Format(time.RFC3339)},
"maxResults": {fmt.Sprintf("%d", maxResults)},
"singleEvents": {"true"},
"orderBy": {"startTime"},
}
var resp CalendarListResponse
if err := c.get(calBase+"/calendars/primary/events?"+params.Encode(), &resp); err != nil {
return nil, err
}
return resp.Items, nil
}
// ── Docs ─────────────────────────────────────────────────────────────────────
type DocsDocument struct {
DocumentID string `json:"documentId"`
Title string `json:"title"`
RevisionID string `json:"revisionId"`
}
func (c *Client) DocsCreate(title string) (*DocsDocument, error) {
var doc DocsDocument
if err := c.post(docsBase+"/documents", map[string]string{"title": title}, &doc); err != nil {
return nil, err
}
return &doc, nil
}
func (c *Client) DocsGet(docID string) (*DocsDocument, error) {
var doc DocsDocument
if err := c.get(fmt.Sprintf("%s/documents/%s", docsBase, docID), &doc); err != nil {
return nil, err
}
return &doc, nil
}
// ── Helpers ──────────────────────────────────────────────────────────────────
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}
// HeaderValue extracts a Gmail message header value by name.
func HeaderValue(msg GmailMessage, name string) string {
for _, h := range msg.Payload.Headers {
if strings.EqualFold(h.Name, name) {
return h.Value
}
}
return ""
}
// FormatEventTime returns a human-readable event time string.
func FormatEventTime(ev CalendarEvent) string {
dt := ev.Start.DateTime
if dt == "" {
return ev.Start.Date // all-day event
}
t, err := time.Parse(time.RFC3339, dt)
if err != nil {
return dt
}
return t.Local().Format("Mon Jan 2, 15:04")
}
// MimeTypeLabel returns a short display label for a Drive MIME type.
func MimeTypeLabel(mime string) string {
switch mime {
case "application/vnd.google-apps.document":
return "Doc"
case "application/vnd.google-apps.spreadsheet":
return "Sheet"
case "application/vnd.google-apps.presentation":
return "Slides"
case "application/vnd.google-apps.folder":
return "📁"
case "application/pdf":
return "PDF"
default:
if strings.HasPrefix(mime, "image/") {
return "Image"
}
return "File"
}
}

View file

@ -7,6 +7,7 @@ import (
"maps"
"net/http"
"sync"
"sync/atomic"
"time"
)
@ -16,6 +17,39 @@ type Server struct {
ready bool
checks map[string]Check
startTime time.Time
metrics *Metrics
}
type Metrics struct {
mu sync.RWMutex
Counters map[string]int64 `json:"counters"`
Latencies map[string][]int64 `json:"latencies_ms"`
LastUpdate time.Time `json:"last_update"`
}
var (
DefaultMetrics = &Metrics{
Counters: make(map[string]int64),
Latencies: make(map[string][]int64),
}
)
func (m *Metrics) RecordCounter(name string, val int64) {
m.mu.Lock()
defer m.mu.Unlock()
m.Counters[name] += val
m.LastUpdate = time.Now()
}
func (m *Metrics) RecordLatency(name string, ms int64) {
m.mu.Lock()
defer m.mu.Unlock()
m.Latencies[name] = append(m.Latencies[name], ms)
// Keep only last 100 samples
if len(m.Latencies[name]) > 100 {
m.Latencies[name] = m.Latencies[name][len(m.Latencies[name])-100:]
}
m.LastUpdate = time.Now()
}
type Check struct {
@ -41,6 +75,7 @@ func NewServer(host string, port int) *Server {
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/metrics", s.metricsHandler)
addr := fmt.Sprintf("%s:%d", host, port)
s.server = &http.Server{
@ -117,6 +152,16 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(resp)
}
func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
DefaultMetrics.mu.RLock()
defer DefaultMetrics.mu.RUnlock()
json.NewEncoder(w).Encode(DefaultMetrics)
}
func (s *Server) readyHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")

View file

@ -406,6 +406,21 @@ func serializeMessages(messages []Message) []any {
"url": mediaURL,
},
})
} else if strings.HasPrefix(mediaURL, "data:application/pdf") {
parts = append(parts, map[string]any{
"type": "file_url",
"file_url": map[string]any{
"url": mediaURL,
},
})
} else if strings.HasPrefix(mediaURL, "data:") {
// Fallback for other media types (e.g. audio, video)
parts = append(parts, map[string]any{
"type": "file_url",
"file_url": map[string]any{
"url": mediaURL,
},
})
}
}

View file

@ -2,6 +2,7 @@ package session
import (
"encoding/json"
"hash/crc32"
"os"
"path/filepath"
"strings"
@ -18,6 +19,7 @@ type Session struct {
Metadata map[string]any `json:"metadata,omitempty"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Checksum uint32 `json:"checksum,omitempty"`
}
type SessionManager struct {
@ -227,10 +229,19 @@ func (sm *SessionManager) Save(key string) error {
}
sm.mu.RUnlock()
// Calculate checksum of JSON without checksum field
snapshot.Checksum = 0
data, err := json.MarshalIndent(snapshot, "", " ")
if err != nil {
return err
}
snapshot.Checksum = crc32.ChecksumIEEE(data)
// Re-marshal with checksum
data, err = json.MarshalIndent(snapshot, "", " ")
if err != nil {
return err
}
sessionPath := filepath.Join(sm.storage, filename+".json")
bakPath := sessionPath + ".bak"
@ -305,10 +316,26 @@ func (sm *SessionManager) loadSessions() error {
// Try to recover from backup
if bakData, bakErr := os.ReadFile(bakPath); bakErr == nil {
if err := json.Unmarshal(bakData, &session); err == nil {
if sm.verifyChecksum(&session) {
sm.sessions[session.Key] = &session
continue
}
}
}
continue
}
if !sm.verifyChecksum(&session) {
// Try backup if primary is corrupted
if bakData, bakErr := os.ReadFile(bakPath); bakErr == nil {
var bakSession Session
if err := json.Unmarshal(bakData, &bakSession); err == nil {
if sm.verifyChecksum(&bakSession) {
sm.sessions[bakSession.Key] = &bakSession
continue
}
}
}
continue
}
@ -333,3 +360,18 @@ func (sm *SessionManager) SetHistory(key string, history []providers.Message) {
session.Updated = time.Now()
}
}
func (sm *SessionManager) verifyChecksum(s *Session) bool {
if s.Checksum == 0 {
return true // Legacy session
}
saved := s.Checksum
s.Checksum = 0
defer func() { s.Checksum = saved }()
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
return false
}
return crc32.ChecksumIEEE(data) == saved
}

View file

@ -174,6 +174,22 @@ type clawhubModerationInfo struct {
IsSuspicious bool `json:"isSuspicious"`
}
type clawhubDetailsResponse struct {
Slug string `json:"slug"`
DisplayName string `json:"displayName"`
Summary string `json:"summary"`
Description string `json:"description"`
Version string `json:"version"`
Author string `json:"author"`
Homepage string `json:"homepage"`
Repository string `json:"repository"`
License string `json:"license"`
Moderation *clawhubModerationInfo `json:"moderation"`
Files []string `json:"files"`
Tools []string `json:"tools"`
Permissions []string `json:"permissions"`
}
func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error) {
if err := utils.ValidateSkillIdentifier(slug); err != nil {
return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error())
@ -209,6 +225,60 @@ func (c *ClawHubRegistry) GetSkillMeta(ctx context.Context, slug string) (*Skill
return meta, nil
}
func (c *ClawHubRegistry) Inspect(ctx context.Context, slug string) (*SkillDetails, error) {
if err := utils.ValidateSkillIdentifier(slug); err != nil {
return nil, fmt.Errorf("invalid slug %q: error: %s", slug, err.Error())
}
// Assuming inspection endpoint is /api/v1/skills/{slug}/inspect or similar
u := c.baseURL + c.skillsPath + "/" + url.PathEscape(slug) + "/inspect"
body, err := c.doGet(ctx, u)
if err != nil {
// Fallback to basic metadata if inspection endpoint is not available
basicMeta, basicErr := c.GetSkillMeta(ctx, slug)
if basicErr != nil {
return nil, fmt.Errorf("skill inspection failed: %w", err)
}
return &SkillDetails{
Slug: basicMeta.Slug,
DisplayName: basicMeta.DisplayName,
Summary: basicMeta.Summary,
IsMalwareBlocked: basicMeta.IsMalwareBlocked,
IsSuspicious: basicMeta.IsSuspicious,
RegistryName: c.Name(),
}, nil
}
var resp clawhubDetailsResponse
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("failed to parse skill inspection details: %w", err)
}
details := &SkillDetails{
Slug: resp.Slug,
DisplayName: resp.DisplayName,
Summary: resp.Summary,
Description: resp.Description,
Version: resp.Version,
Author: resp.Author,
Homepage: resp.Homepage,
Repository: resp.Repository,
License: resp.License,
Files: resp.Files,
Tools: resp.Tools,
Permissions: resp.Permissions,
RegistryName: c.Name(),
}
if resp.Moderation != nil {
details.IsMalwareBlocked = resp.Moderation.IsMalwareBlocked
details.IsSuspicious = resp.Moderation.IsSuspicious
}
return details, nil
}
// --- DownloadAndInstall ---
// DownloadAndInstall fetches metadata (with fallback), resolves version,

View file

@ -33,6 +33,25 @@ type SkillMeta struct {
RegistryName string `json:"registry_name"`
}
// SkillDetails provides in-depth information about a skill for trust/safety inspection.
type SkillDetails struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Summary string `json:"summary"`
Description string `json:"description"`
Version string `json:"version"`
Author string `json:"author"`
Homepage string `json:"homepage"`
Repository string `json:"repository"`
License string `json:"license"`
IsMalwareBlocked bool `json:"is_malware_blocked"`
IsSuspicious bool `json:"is_suspicious"`
Files []string `json:"files"` // List of files in the skill
Tools []string `json:"tools"` // List of tools/commands provided
Permissions []string `json:"permissions"` // Required permissions
RegistryName string `json:"registry_name"`
}
// InstallResult is returned by DownloadAndInstall to carry metadata
// back to the caller for moderation and user messaging.
type InstallResult struct {
@ -55,6 +74,8 @@ type SkillRegistry interface {
// installs the skill to targetDir. Returns an InstallResult with metadata
// for the caller to use for moderation and user messaging.
DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error)
// Inspect retrieves detailed information about a skill for safety review.
Inspect(ctx context.Context, slug string) (*SkillDetails, error)
}
// RegistryConfig holds configuration for all skill registries.

172
pkg/tools/browser.go Normal file
View file

@ -0,0 +1,172 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/chromedp/chromedp"
"github.com/sipeed/picoclaw/pkg/media"
)
// BrowserTool provides browser automation capabilities using chromedp.
type BrowserTool struct {
workspace string
mediaStore media.MediaStore
ctx context.Context
cancel context.CancelFunc
}
func NewBrowserTool(workspace string, store media.MediaStore) *BrowserTool {
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.NoSandbox,
chromedp.Headless,
chromedp.DisableGPU,
)
allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...)
browserCtx, _ := chromedp.NewContext(allocCtx)
return &BrowserTool{
workspace: workspace,
mediaStore: store,
ctx: browserCtx,
cancel: cancel,
}
}
func (t *BrowserTool) Name() string { return "browser" }
func (t *BrowserTool) Description() string {
return "Automate a web browser to navigate, click, type, and take screenshots."
}
func (t *BrowserTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"navigate", "click", "type", "screenshot", "get_html"},
"description": "The action to perform.",
},
"url": map[string]any{
"type": "string",
"description": "URL for 'navigate' action.",
},
"selector": map[string]any{
"type": "string",
"description": "CSS selector for 'click' or 'type' action.",
},
"text": map[string]any{
"type": "string",
"description": "Text to type for 'type' action.",
},
"filename": map[string]any{
"type": "string",
"description": "Output filename for 'screenshot' action (e.g., 'view.png').",
},
},
"required": []string{"action"},
}
}
func (t *BrowserTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, _ := args["action"].(string)
// Ensure the browser context is still active
if t.ctx.Err() != nil {
allocCtx, _ := chromedp.NewExecAllocator(context.Background(), chromedp.DefaultExecAllocatorOptions[:]...)
t.ctx, _ = chromedp.NewContext(allocCtx)
}
switch action {
case "navigate":
urlStr, _ := args["url"].(string)
if urlStr == "" {
return ErrorResult("url is required for navigate")
}
if err := chromedp.Run(t.ctx, chromedp.Navigate(urlStr)); err != nil {
return ErrorResult(fmt.Sprintf("navigation failed: %v", err))
}
return UserResult(fmt.Sprintf("Navigated to %s", urlStr))
case "click":
selector, _ := args["selector"].(string)
if selector == "" {
return ErrorResult("selector is required for click")
}
if err := chromedp.Run(t.ctx, chromedp.Click(selector)); err != nil {
return ErrorResult(fmt.Sprintf("click failed: %v", err))
}
return UserResult(fmt.Sprintf("Clicked element: %s", selector))
case "type":
selector, _ := args["selector"].(string)
text, _ := args["text"].(string)
if selector == "" || text == "" {
return ErrorResult("selector and text are required for type")
}
if err := chromedp.Run(t.ctx, chromedp.SendKeys(selector, text)); err != nil {
return ErrorResult(fmt.Sprintf("typing failed: %v", err))
}
return UserResult(fmt.Sprintf("Typed into %s", selector))
case "screenshot":
filename, _ := args["filename"].(string)
if filename == "" {
filename = fmt.Sprintf("screenshot-%d.png", time.Now().Unix())
}
if !strings.HasSuffix(strings.ToLower(filename), ".png") {
filename += ".png"
}
path := filepath.Join(t.workspace, filename)
var buf []byte
if err := chromedp.Run(t.ctx, chromedp.CaptureScreenshot(&buf)); err != nil {
return ErrorResult(fmt.Sprintf("screenshot failed: %v", err))
}
if err := os.WriteFile(path, buf, 0o644); err != nil {
return ErrorResult(fmt.Sprintf("failed to save screenshot: %v", err))
}
channel := ToolChannel(ctx)
chatID := ToolChatID(ctx)
scope := fmt.Sprintf("tool:browser:screenshot:%s:%s", channel, chatID)
ref := path
if t.mediaStore != nil {
if r, err := t.mediaStore.Store(path, media.MediaMeta{
Filename: filename,
ContentType: "image/png",
Source: "tool:browser",
}, scope); err == nil {
ref = r
}
}
return MediaResult(fmt.Sprintf("Screenshot captured: %s", filename), []string{ref})
case "get_html":
var html string
if err := chromedp.Run(t.ctx, chromedp.OuterHTML("html", &html)); err != nil {
return ErrorResult(fmt.Sprintf("failed to get HTML: %v", err))
}
// Truncate if too long
if len(html) > 50000 {
html = html[:50000] + "\n... (truncated)"
}
return &ToolResult{ForLLM: html, ForUser: "Captured page HTML"}
default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
}
}
func (t *BrowserTool) Stop() {
if t.cancel != nil {
t.cancel()
}
}

View file

@ -12,14 +12,20 @@ import (
"github.com/sipeed/picoclaw/pkg/logger"
)
type GoogleTool struct{}
type GoogleTool struct {
manager ChannelManagerGetter
}
func NewGoogleTool(manager ChannelManagerGetter) *GoogleTool {
return &GoogleTool{manager: manager}
}
func (t *GoogleTool) Name() string {
return "google"
}
func (t *GoogleTool) Description() string {
return "Access Google services like Gmail and Calendar. Actions: 'list_emails', 'list_events'. Use 'list_emails' to get recent messages (subject, snippet). Use 'list_events' to get upcoming calendar events (summary, start/end time)."
return "Access Google Workspace (GWS) services like Gmail and Calendar. Actions: 'list_emails', 'search_emails', 'read_thread', 'list_events', 'sync'. Use 'sync' with 'search_emails' or 'read_thread' to save results into the agent's contextual memory."
}
func (t *GoogleTool) Parameters() map[string]any {
@ -28,9 +34,17 @@ func (t *GoogleTool) Parameters() map[string]any {
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"list_emails", "list_events"},
"enum": []string{"list_emails", "search_emails", "read_thread", "list_events", "sync"},
"description": "The service action to perform.",
},
"query": map[string]any{
"type": "string",
"description": "Query string for 'search_emails'.",
},
"thread_id": map[string]any{
"type": "string",
"description": "Thread ID for 'read_thread'.",
},
"count": map[string]any{
"type": "integer",
"default": 10,
@ -67,8 +81,16 @@ func (t *GoogleTool) Execute(ctx context.Context, args map[string]any) *ToolResu
switch action {
case "list_emails":
return t.listEmails(ctx, cred, count)
case "search_emails":
query, _ := args["query"].(string)
return t.searchEmails(ctx, cred, query, count)
case "read_thread":
threadID, _ := args["thread_id"].(string)
return t.readThread(ctx, cred, threadID)
case "list_events":
return t.listEvents(ctx, cred, count)
case "sync":
return t.syncCommunications(ctx, cred, args)
default:
return ErrorResult("Unknown action")
}
@ -139,6 +161,124 @@ func (t *GoogleTool) listEmails(ctx context.Context, cred *auth.AuthCredential,
return SilentResult(fmt.Sprintf("Recent Emails:\n%s", join(emails, "\n\n")))
}
func (t *GoogleTool) searchEmails(ctx context.Context, cred *auth.AuthCredential, query string, maxResults int) *ToolResult {
url := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?q=%s&maxResults=%d", url.QueryEscape(query), maxResults)
return t.fetchAndFormatMessages(ctx, cred, url)
}
func (t *GoogleTool) fetchAndFormatMessages(ctx context.Context, cred *auth.AuthCredential, url string) *ToolResult {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "Bearer "+cred.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("API request failed: %v", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return ErrorResult(fmt.Sprintf("Gmail API error (%d): %s", resp.StatusCode, string(body)))
}
var listResp struct {
Messages []struct {
ID string `json:"id"`
ThreadID string `json:"threadId"`
} `json:"messages"`
}
if err := json.NewDecoder(resp.Body).Decode(&listResp); err != nil {
return ErrorResult(fmt.Sprintf("Failed to decode Gmail list: %v", err))
}
var emails []string
for _, m := range listResp.Messages {
msgURL := "https://gmail.googleapis.com/gmail/v1/users/me/messages/" + m.ID
mReq, _ := http.NewRequestWithContext(ctx, "GET", msgURL, nil)
mReq.Header.Set("Authorization", "Bearer "+cred.AccessToken)
mResp, err := http.DefaultClient.Do(mReq)
if err != nil {
continue
}
var msg struct {
Snippet string `json:"snippet"`
Payload struct {
Headers []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"headers"`
} `json:"payload"`
}
_ = json.NewDecoder(mResp.Body).Decode(&msg)
mResp.Body.Close()
subject := "No Subject"
from := "Unknown"
for _, h := range msg.Payload.Headers {
if h.Name == "Subject" {
subject = h.Value
} else if h.Name == "From" {
from = h.Value
}
}
emails = append(emails, fmt.Sprintf("- From: %s\n Subject: %s\n Snippet: %s\n ThreadID: %s", from, subject, msg.Snippet, m.ThreadID))
}
if len(emails) == 0 {
return SilentResult("No messages found.")
}
return SilentResult(fmt.Sprintf("Gmail Search Results:\n%s", join(emails, "\n\n")))
}
func (t *GoogleTool) readThread(ctx context.Context, cred *auth.AuthCredential, threadID string) *ToolResult {
url := "https://gmail.googleapis.com/gmail/v1/users/me/threads/" + threadID
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
req.Header.Set("Authorization", "Bearer "+cred.AccessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("API request failed: %v", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return ErrorResult(fmt.Sprintf("Gmail API error (%d): %s", resp.StatusCode, string(body)))
}
var threadResp struct {
Messages []struct {
Snippet string `json:"snippet"`
Payload struct {
Headers []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"headers"`
} `json:"payload"`
} `json:"messages"`
}
if err := json.NewDecoder(resp.Body).Decode(&threadResp); err != nil {
return ErrorResult(fmt.Sprintf("Failed to decode Gmail thread: %v", err))
}
var threadMsgs []string
for _, msg := range threadResp.Messages {
from := "Unknown"
date := "Unknown Date"
for _, h := range msg.Payload.Headers {
if h.Name == "From" {
from = h.Value
} else if h.Name == "Date" {
date = h.Value
}
}
threadMsgs = append(threadMsgs, fmt.Sprintf("[%s] From: %s\n%s", date, from, msg.Snippet))
}
return SilentResult(fmt.Sprintf("Gmail Thread %s:\n%s", threadID, join(threadMsgs, "\n\n---\n\n")))
}
func (t *GoogleTool) listEvents(ctx context.Context, cred *auth.AuthCredential, maxResults int) *ToolResult {
now := time.Now().Format(time.RFC3339)
url := fmt.Sprintf("https://www.googleapis.com/calendar/v3/calendars/primary/events?timeMin=%s&maxResults=%d&singleEvents=true&orderBy=startTime", now, maxResults)
@ -193,6 +333,51 @@ func (t *GoogleTool) listEvents(ctx context.Context, cred *auth.AuthCredential,
return SilentResult(fmt.Sprintf("Upcoming Calendar Events:\n%s", join(events, "\n")))
}
func (t *GoogleTool) syncCommunications(ctx context.Context, cred *auth.AuthCredential, args map[string]any) *ToolResult {
query, _ := args["query"].(string)
threadID, _ := args["thread_id"].(string)
count := 10
if c, ok := args["count"].(float64); ok {
count = int(c)
}
var result *ToolResult
var syncKey string
if threadID != "" {
result = t.readThread(ctx, cred, threadID)
syncKey = fmt.Sprintf("Gmail Thread (%s)", threadID)
} else if query != "" {
result = t.searchEmails(ctx, cred, query, count)
syncKey = fmt.Sprintf("Gmail Search (%q)", query)
} else {
result = t.listEmails(ctx, cred, count)
syncKey = "Recent Gmail"
}
if result.IsError {
return result
}
if t.manager == nil {
return result
}
if getter, ok := t.manager.(interface{ GetMemoryStore() any }); ok {
if ms := getter.GetMemoryStore(); ms != nil {
if writer, ok := ms.(interface{ AppendCommunications(string) error }); ok {
err := writer.AppendCommunications(fmt.Sprintf("Gmail Sync (%s) @ %s:\n%s", syncKey, time.Now().Format("2006-01-02 15:04"), result.ForLLM))
if err != nil {
return ErrorResult(fmt.Sprintf("Failed to sync to memory: %v", err))
}
return SilentResult(fmt.Sprintf("%s synced to contextual memory.", syncKey))
}
}
}
return result
}
func join(s []string, sep string) string {
res := ""
for i, v := range s {

172
pkg/tools/image.go Normal file
View file

@ -0,0 +1,172 @@
package tools
import (
"context"
"fmt"
"image"
"image/color"
"image/draw"
"os"
"path/filepath"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/sipeed/picoclaw/pkg/media"
)
// ImageTool provides advanced image manipulation capabilities using imaging.
type ImageTool struct {
workspace string
mediaStore media.MediaStore
}
func NewImageTool(workspace string, store media.MediaStore) *ImageTool {
return &ImageTool{
workspace: workspace,
mediaStore: store,
}
}
func (t *ImageTool) Name() string { return "image" }
func (t *ImageTool) Description() string {
return "Inspect, resize, crop, and manipulate images. Supports media:// and local paths."
}
func (t *ImageTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"inspect", "resize", "crop", "annotate"},
"description": "The action to perform.",
},
"path": map[string]any{
"type": "string",
"description": "Path to the input image (local path or media:// reference).",
},
"width": map[string]any{
"type": "integer",
"description": "Target width for 'resize'.",
},
"height": map[string]any{
"type": "integer",
"description": "Target height for 'resize'.",
},
"x": map[string]any{"type": "integer", "description": "X coordinate for 'crop'."},
"y": map[string]any{"type": "integer", "description": "Y coordinate for 'crop'."},
"w": map[string]any{"type": "integer", "description": "Width for 'crop'."},
"h": map[string]any{"type": "integer", "description": "Height for 'crop'."},
"output": map[string]any{
"type": "string",
"description": "Output filename (e.g., 'edited.png').",
},
},
"required": []string{"action", "path"},
}
}
func (t *ImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, _ := args["action"].(string)
inputPath, _ := args["path"].(string)
resolvedPath, err := t.resolvePath(inputPath)
if err != nil {
return ErrorResult(err.Error())
}
src, err := imaging.Open(resolvedPath)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to open image: %v", err))
}
switch action {
case "inspect":
bounds := src.Bounds()
return UserResult(fmt.Sprintf("Image dimensions: %dx%d px", bounds.Dx(), bounds.Dy()))
case "resize":
width, _ := args["width"].(float64)
height, _ := args["height"].(float64)
if width == 0 && height == 0 {
return ErrorResult("width or height must be specified for resize")
}
dst := imaging.Resize(src, int(width), int(height), imaging.Lanczos)
return t.saveAndReturn(ctx, dst, args)
case "crop":
x, _ := args["x"].(float64)
y, _ := args["y"].(float64)
w, _ := args["w"].(float64)
h, _ := args["h"].(float64)
if w == 0 || h == 0 {
return ErrorResult("w and h must be specified for crop")
}
dst := imaging.Crop(src, image.Rect(int(x), int(y), int(x+w), int(y+h)))
return t.saveAndReturn(ctx, dst, args)
case "annotate":
// Placeholder for complex annotation.
// We'll just draw a red border for now to demonstrate manipulation.
bounds := src.Bounds()
dst := image.NewRGBA(bounds)
draw.Draw(dst, bounds, src, bounds.Min, draw.Src)
red := color.RGBA{255, 0, 0, 255}
// Draw simple border lines
for i := 0; i < 5; i++ {
draw.Draw(dst, image.Rect(bounds.Min.X, bounds.Min.Y+i, bounds.Max.X, bounds.Min.Y+i+1), &image.Uniform{red}, image.Point{}, draw.Src)
draw.Draw(dst, image.Rect(bounds.Min.X, bounds.Max.Y-i-1, bounds.Max.X, bounds.Max.Y-i), &image.Uniform{red}, image.Point{}, draw.Src)
draw.Draw(dst, image.Rect(bounds.Min.X+i, bounds.Min.Y, bounds.Min.X+i+1, bounds.Max.Y), &image.Uniform{red}, image.Point{}, draw.Src)
draw.Draw(dst, image.Rect(bounds.Max.X-i-1, bounds.Min.Y, bounds.Max.X-i, bounds.Max.Y), &image.Uniform{red}, image.Point{}, draw.Src)
}
return t.saveAndReturn(ctx, dst, args)
default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
}
}
func (t *ImageTool) resolvePath(input string) (string, error) {
if strings.HasPrefix(input, "media://") {
if t.mediaStore == nil {
return "", fmt.Errorf("media store not configured")
}
return t.mediaStore.Resolve(input)
}
// Fallback to workspace path validation
return validatePath(input, t.workspace, true)
}
func (t *ImageTool) saveAndReturn(ctx context.Context, img image.Image, args map[string]any) *ToolResult {
output, _ := args["output"].(string)
if output == "" {
output = fmt.Sprintf("output-%d.png", time.Now().Unix())
}
if !strings.HasSuffix(strings.ToLower(output), ".png") && !strings.HasSuffix(strings.ToLower(output), ".jpg") {
output += ".png"
}
path := filepath.Join(t.workspace, output)
if err := imaging.Save(img, path); err != nil {
return ErrorResult(fmt.Sprintf("failed to save image: %v", err))
}
channel := ToolChannel(ctx)
chatID := ToolChatID(ctx)
scope := fmt.Sprintf("tool:image:manipulate:%s:%s", channel, chatID)
ref := path
if t.mediaStore != nil {
if r, err := t.mediaStore.Store(path, media.MediaMeta{
Filename: output,
ContentType: "image/png",
Source: "tool:image",
}, scope); err == nil {
ref = r
}
}
return MediaResult(fmt.Sprintf("Image %q processed successfully", output), []string{ref})
}

186
pkg/tools/pdf.go Normal file
View file

@ -0,0 +1,186 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/jung-kurt/gofpdf/v2"
"github.com/sipeed/picoclaw/pkg/media"
)
// PDFTool provides capabilities for creating, translating, and extracting PDF documents.
type PDFTool struct {
workspace string
restrict bool
mediaStore media.MediaStore
}
func NewPDFTool(workspace string, restrict bool, store media.MediaStore) *PDFTool {
return &PDFTool{
workspace: workspace,
restrict: restrict,
mediaStore: store,
}
}
func (t *PDFTool) Name() string { return "pdf" }
func (t *PDFTool) Description() string {
return "Create and export PDF documents. Use 'create' to generate a PDF from text (e.g. translations). " +
"For translation tasks: translate the full text first, then call pdf with action='create', content=<full translation>, output=<filename.pdf>."
}
func (t *PDFTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"create", "extract"},
"description": "Action: 'create' builds a PDF from text content, 'extract' reads a PDF file path and returns its text.",
},
"content": map[string]any{
"type": "string",
"description": "Text content to write into the PDF (for 'create'). Pass the COMPLETE translated text — never truncate.",
},
"title": map[string]any{
"type": "string",
"description": "Optional title displayed at the top of the PDF (for 'create').",
},
"path": map[string]any{
"type": "string",
"description": "File path or media:// ref of a PDF to extract text from (for 'extract').",
},
"output": map[string]any{
"type": "string",
"description": "Output filename for 'create' (e.g. 'translation_ro.pdf'). Defaults to 'document.pdf'.",
},
},
"required": []string{"action"},
}
}
func (t *PDFTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, _ := args["action"].(string)
switch action {
case "create":
return t.handleCreate(ctx, args)
case "extract":
return t.handleExtract(args)
default:
return ErrorResult(fmt.Sprintf("unknown pdf action: %s. Use 'create' or 'extract'.", action))
}
}
// handleCreate builds a properly paginated, Unicode-safe PDF from the given content.
func (t *PDFTool) handleCreate(_ context.Context, args map[string]any) *ToolResult {
content, _ := args["content"].(string)
title, _ := args["title"].(string)
output, _ := args["output"].(string)
if content == "" {
return ErrorResult("content is required for 'create' action")
}
if output == "" {
output = "document.pdf"
}
if !strings.HasSuffix(strings.ToLower(output), ".pdf") {
output += ".pdf"
}
outPath := filepath.Join(t.workspace, output)
if t.restrict {
var err error
outPath, err = validatePath(output, t.workspace, true)
if err != nil {
return ErrorResult(err.Error())
}
}
// Ensure parent directory exists
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return ErrorResult(fmt.Sprintf("failed to create output directory: %v", err))
}
pdf := gofpdf.New("P", "mm", "A4", "")
pdf.SetMargins(20, 20, 20)
pdf.SetAutoPageBreak(true, 20)
// Use ISO-8859-2 encoder for full coverage of EU Latin languages (Romanian, etc.)
pdf.SetFont("Helvetica", "", 12)
tr := pdf.UnicodeTranslatorFromDescriptor("iso-8859-2")
pdf.AddPage()
// Optional title
if title != "" {
pdf.SetFont("Helvetica", "B", 16)
pdf.MultiCell(170, 10, tr(title), "", "C", false)
pdf.Ln(6)
pdf.SetFont("Helvetica", "", 12)
}
// Write body — split on blank lines to preserve paragraph structure
paragraphs := strings.Split(content, "\n\n")
for _, para := range paragraphs {
para = strings.TrimSpace(para)
if para == "" {
pdf.Ln(4)
continue
}
// Within a paragraph, keep newlines as line breaks
lines := strings.Split(para, "\n")
for _, line := range lines {
pdf.MultiCell(170, 7, tr(line), "", "L", false)
}
pdf.Ln(4) // paragraph spacing
}
if err := pdf.OutputFileAndClose(outPath); err != nil {
return ErrorResult(fmt.Sprintf("failed to write PDF: %v", err))
}
scope := "tool:pdf:create"
ref := outPath
if t.mediaStore != nil {
if r, storeErr := t.mediaStore.Store(outPath, media.MediaMeta{
Filename: output,
ContentType: "application/pdf",
Source: "tool:pdf",
}, scope); storeErr == nil {
ref = r
}
}
return MediaResult(fmt.Sprintf("PDF %q created (%d pages). Sending now.", output, pdf.PageCount()), []string{ref})
}
// handleExtract returns a prompt message telling the agent to read the file as an attachment.
func (t *PDFTool) handleExtract(args map[string]any) *ToolResult {
path, _ := args["path"].(string)
if path == "" {
return ErrorResult("path is required for 'extract' action")
}
// Resolve media refs
resolved := path
if t.restrict && !strings.HasPrefix(path, "media://") {
var err error
resolved, err = validatePath(path, t.workspace, true)
if err != nil {
return ErrorResult(err.Error())
}
}
return UserResult(fmt.Sprintf(
"To extract text from %q, attach the file directly in the chat — I can read it using my vision capability. "+
"Alternatively, if you share the file path I can attempt to parse it as a document.",
filepath.Base(resolved),
))
}

130
pkg/tools/skills_inspect.go Normal file
View file

@ -0,0 +1,130 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/skills"
)
// InspectSkillTool allows the LLM agent to inspect a skill's details before installation.
type InspectSkillTool struct {
registryMgr *skills.RegistryManager
}
// NewInspectSkillTool creates a new InspectSkillTool.
func NewInspectSkillTool(registryMgr *skills.RegistryManager) *InspectSkillTool {
return &InspectSkillTool{
registryMgr: registryMgr,
}
}
func (t *InspectSkillTool) Name() string {
return "inspect_skill"
}
func (t *InspectSkillTool) Description() string {
return "Retrieve in-depth information about a skill (slug, version, files, tools, permissions, moderation status) before installation. Use this for trust and safety review."
}
func (t *InspectSkillTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"slug": map[string]any{
"type": "string",
"description": "The unique identifier of the skill to inspect (e.g., 'agentbox-openrouter')",
},
"registry": map[string]any{
"type": "string",
"description": "Optional registry name (e.g., 'clawhub')",
},
},
"required": []string{"slug"},
}
}
func (t *InspectSkillTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
slug, ok := args["slug"].(string)
if !ok || slug == "" {
return ErrorResult("slug is required")
}
registryName, _ := args["registry"].(string)
var reg skills.SkillRegistry
if registryName != "" {
reg = t.registryMgr.GetRegistry(registryName)
if reg == nil {
return ErrorResult(fmt.Sprintf("registry %q not found", registryName))
}
} else {
// Try to find the skill in any registry (simplification: just use the first available one for now or loop)
// Usually find_skills should provide the registry name.
// For robustness, if not provided, we can't easily guess which registry has it without searching.
// However, ClawHub is usually the default.
reg = t.registryMgr.GetRegistry("clawhub")
if reg == nil {
return ErrorResult("no skill registries available")
}
}
details, err := reg.Inspect(ctx, slug)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to inspect skill: %v", err))
}
return SilentResult(formatSkillDetails(details))
}
func formatSkillDetails(d *skills.SkillDetails) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("### Skill Inspection: %s\n\n", d.Slug))
sb.WriteString(fmt.Sprintf("- **Name:** %s\n", d.DisplayName))
sb.WriteString(fmt.Sprintf("- **Version:** %s\n", d.Version))
sb.WriteString(fmt.Sprintf("- **Author:** %s\n", d.Author))
sb.WriteString(fmt.Sprintf("- **License:** %s\n", d.License))
sb.WriteString(fmt.Sprintf("- **Registry:** %s\n", d.RegistryName))
if d.Summary != "" {
sb.WriteString(fmt.Sprintf("\n**Summary:**\n%s\n", d.Summary))
}
if d.Description != "" {
sb.WriteString(fmt.Sprintf("\n**Description:**\n%s\n", d.Description))
}
sb.WriteString("\n#### Safety & Trust:\n")
if d.IsMalwareBlocked {
sb.WriteString("- 🛡️ **Clean:** No known malware detected.\n")
} else if d.IsSuspicious {
sb.WriteString("- ⚠️ **Suspicious:** This skill has been flagged for manual review.\n")
} else {
sb.WriteString("- **Status:** Verified according to registry standards.\n")
}
if len(d.Permissions) > 0 {
sb.WriteString("\n**Requested Permissions:**\n")
for _, p := range d.Permissions {
sb.WriteString(fmt.Sprintf("- `%s`\n", p))
}
}
if len(d.Tools) > 0 {
sb.WriteString("\n**Tools Provided:**\n")
for _, t := range d.Tools {
sb.WriteString(fmt.Sprintf("- `%s`\n", t))
}
}
if len(d.Files) > 0 {
sb.WriteString("\n**Files:**\n")
for _, f := range d.Files {
sb.WriteString(fmt.Sprintf("- `%s`\n", f))
}
}
sb.WriteString("\n*Use `install_skill` to install this skill if you trust its contents.*")
return sb.String()
}

147
pkg/tools/speech.go Normal file
View file

@ -0,0 +1,147 @@
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/google/uuid"
"github.com/sipeed/picoclaw/pkg/media"
)
// SynthesizerProvider is an interface to get a speech synthesizer.
type SynthesizerProvider interface {
GetSynthesizer(name string) (any, bool)
}
// MediaStoreGetter is an interface to get the media store.
type MediaStoreGetter interface {
GetMediaStore() media.MediaStore
}
// WorkspaceGetter is an interface to get the agent's workspace.
type WorkspaceGetter interface {
GetWorkspace() string
}
// SpeechTool allows the agent to generate speech audio from text.
type SpeechTool struct {
manager any // AgentLoop/Manager
}
// NewSpeechTool creates a new SpeechTool.
func NewSpeechTool(manager any) *SpeechTool {
return &SpeechTool{
manager: manager,
}
}
func (t *SpeechTool) Name() string {
return "speech"
}
func (t *SpeechTool) Description() string {
return "Convert text to speech audio. Action: 'speak'. Generates an MP3 file and returns a media:// reference."
}
func (t *SpeechTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"speak"},
"description": "Action to perform.",
},
"text": map[string]any{
"type": "string",
"description": "The text to convert to speech.",
},
"provider": map[string]any{
"type": "string",
"description": "Optional: speech provider (e.g., 'elevenlabs'). Default is the system default.",
},
},
"required": []string{"action", "text"},
}
}
func (t *SpeechTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, _ := args["action"].(string)
text, _ := args["text"].(string)
providerName, _ := args["provider"].(string)
if action != "speak" {
return ErrorResult(fmt.Sprintf("Unsupported action: %s", action))
}
if text == "" {
return ErrorResult("text is required")
}
// 1. Get Synthesizer
var synth any
if getter, ok := t.manager.(SynthesizerProvider); ok {
if s, found := getter.GetSynthesizer(providerName); found {
synth = s
}
}
if synth == nil {
return ErrorResult("No speech synthesizer available.")
}
// 2. Synthesize
type synthesizer interface {
Synthesize(ctx context.Context, text string) ([]byte, error)
}
s, ok := synth.(synthesizer)
if !ok {
return ErrorResult("Internal error: invalid synthesizer instance.")
}
audioData, err := s.Synthesize(ctx, text)
if err != nil {
return ErrorResult(fmt.Sprintf("Speech synthesis failed: %v", err))
}
// 3. Save to workspace and register in MediaStore
var workspace string
if wg, ok := t.manager.(WorkspaceGetter); ok {
workspace = wg.GetWorkspace()
}
if workspace == "" {
workspace = os.TempDir()
}
mediaDir := filepath.Join(workspace, "media")
_ = os.MkdirAll(mediaDir, 0o755)
filename := fmt.Sprintf("speech-%s.mp3", uuid.New().String()[:8])
localPath := filepath.Join(mediaDir, filename)
if err := os.WriteFile(localPath, audioData, 0o644); err != nil {
return ErrorResult(fmt.Errorf("failed to save audio file: %w", err).Error())
}
// 4. Register in MediaStore
var store media.MediaStore
if sg, ok := t.manager.(MediaStoreGetter); ok {
store = sg.GetMediaStore()
}
if store != nil {
ref, err := store.Store(localPath, media.MediaMeta{
Filename: filename,
ContentType: "audio/mpeg",
Source: "tool:speech",
}, "agent_session") // TODO: pass actual scope if available
if err != nil {
return ErrorResult(fmt.Sprintf("Failed to register media: %v", err))
}
return SilentResult(fmt.Sprintf("Speech generated successfully. Audio reference: %s", ref))
}
return SilentResult(fmt.Sprintf("Speech generated successfully. Saved to: %s", localPath))
}

View file

@ -19,6 +19,7 @@ type SubagentTask struct {
Status string
Result string
Created int64
Type string // "default", "web_dev", "ui_designer", "researcher"
}
type SubagentManager struct {
@ -112,10 +113,28 @@ func (sm *SubagentManager) runTask(ctx context.Context, task *SubagentTask, call
task.Status = "running"
task.Created = time.Now().UnixMilli()
// Build system prompt for subagent
systemPrompt := `You are a subagent. Complete the given task independently and report the result.
You have access to tools - use them as needed to complete your task.
After completing the task, provide a clear summary of what was done.`
// Build system prompt for subagent based on type
systemPrompt := "You are a subagent. Complete the given task independently and report the result."
switch task.Type {
case "web_dev":
systemPrompt = `You are a specialized Web Development subagent.
Your goal is to build, debug, or improve web applications (HTML/CSS/JS).
You have access to a browser tool - use it to verify your work and take screenshots for the user.
Focus on clean code, responsiveness, and functional correctness.`
case "ui_designer":
systemPrompt = `You are a specialized UI/UX Design subagent.
Focus on aesthetics, layout, color theory, and user experience.
Use the browser and image tools to inspect designs and provide visual feedback or mockups.
Your goal is to make things look premium, modern, and high-quality.`
case "researcher":
systemPrompt = `You are a specialized Research subagent.
Your goal is to find deep, accurate, and synthesized information on the web.
Use search tools extensively and cross-reference multiple sources.
Provide detailed summaries with citations.`
}
systemPrompt += "\nYou have access to tools - use them as needed to complete your task.\nAfter completing the task, provide a clear summary of what was done."
messages := []providers.Message{
{
@ -263,6 +282,11 @@ func (t *SubagentTool) Parameters() map[string]any {
"type": "string",
"description": "Optional short label for the task (for display)",
},
"type": map[string]any{
"type": "string",
"enum": []string{"default", "web_dev", "ui_designer", "researcher"},
"description": "Specialized agent type with custom system prompts and focuses.",
},
},
"required": []string{"task"},
}
@ -275,16 +299,31 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
}
label, _ := args["label"].(string)
agentType, _ := args["type"].(string)
if agentType == "" {
agentType = "default"
}
if t.manager == nil {
return ErrorResult("Subagent manager not configured").WithError(fmt.Errorf("manager is nil"))
}
// Build messages for subagent
// Build messages for subagent with specialized system prompt
systemPrompt := "You are a subagent. Complete the given task independently and provide a clear, concise result."
switch agentType {
case "web_dev":
systemPrompt = "You are a specialized Web Development subagent. Build, debug, or improve web applications. Use the browser to verify work."
case "ui_designer":
systemPrompt = "You are a specialized UI/UX Design subagent. Focus on aesthetics and premium visual quality."
case "researcher":
systemPrompt = "You are a specialized Research subagent. Find deep, accurate, and synthesized information."
}
// Build messages for subagent with specialized system prompt
messages := []providers.Message{
{
Role: "system",
Content: "You are a subagent. Complete the given task independently and provide a clear, concise result.",
Content: systemPrompt,
},
{
Role: "user",
@ -292,17 +331,6 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]any) *ToolRe
},
}
// Use RunToolLoop to execute with tools (same as async SpawnTool)
sm := t.manager
sm.mu.RLock()
tools := sm.tools
maxIter := sm.maxIterations
maxTokens := sm.maxTokens
temperature := sm.temperature
hasMaxTokens := sm.hasMaxTokens
hasTemperature := sm.hasTemperature
sm.mu.RUnlock()
var llmOptions map[string]any
if hasMaxTokens || hasTemperature {
llmOptions = map[string]any{}

124
pkg/tools/whatsapp.go Normal file
View file

@ -0,0 +1,124 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/bus"
)
// HistoryProvider matches the interface defined in pkg/channels/interfaces.go
type HistoryProvider interface {
FetchHistory(ctx context.Context, chatID string, limit int) ([]bus.InboundMessage, error)
}
// ChannelManagerGetter is an interface to get a channel by name.
type ChannelManagerGetter interface {
GetChannel(name string) (any, bool)
}
// WhatsAppTool allows the agent to fetch message history from WhatsApp.
type WhatsAppTool struct {
manager ChannelManagerGetter
}
// NewWhatsAppTool creates a new WhatsAppTool.
func NewWhatsAppTool(manager ChannelManagerGetter) *WhatsAppTool {
return &WhatsAppTool{
manager: manager,
}
}
func (t *WhatsAppTool) Name() string {
return "whatsapp"
}
func (t *WhatsAppTool) Description() string {
return "Interact with WhatsApp. Actions: 'list_messages', 'sync'. Use 'list_messages' to fetch recent chat history, and 'sync' to save the latest messages from a chat into the agent's contextual memory."
}
func (t *WhatsAppTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"action": map[string]any{
"type": "string",
"enum": []string{"list_messages", "sync"},
"description": "Action to perform.",
},
"chat_id": map[string]any{
"type": "string",
"description": "The WhatsApp JID or phone number (e.g., '1234567890@s.whatsapp.net' or a group JID).",
},
"limit": map[string]any{
"type": "integer",
"default": 10,
"description": "Number of messages to retrieve (max 50).",
},
},
"required": []string{"action", "chat_id"},
}
}
func (t *WhatsAppTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
action, _ := args["action"].(string)
chatID, _ := args["chat_id"].(string)
if chatID == "" {
return ErrorResult("chat_id is required")
}
limit := 10
if l, ok := args["limit"].(float64); ok {
limit = int(l)
}
if limit > 50 {
limit = 50
}
// Try to find the whatsapp_native channel
var hp HistoryProvider
if ch, ok := t.manager.GetChannel("whatsapp_native"); ok {
if provider, ok := ch.(HistoryProvider); ok {
hp = provider
}
}
if hp == nil {
return ErrorResult("WhatsApp history retrieval is not supported (requires whatsapp_native).")
}
messages, err := hp.FetchHistory(ctx, chatID, limit)
if err != nil {
return ErrorResult(fmt.Sprintf("Failed to fetch WhatsApp history: %v", err))
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("WhatsApp History for %s:\n\n", chatID))
for _, m := range messages {
sender := "Me"
if m.SenderID == chatID {
sender = "Contact"
}
sb.WriteString(fmt.Sprintf("[%s] %s: %s\n", m.Timestamp.Format("2006-01-02 15:04"), sender, m.Content))
}
resultText := sb.String()
if action == "sync" {
if getter, ok := t.manager.(interface{ GetMemoryStore() any }); ok {
if ms := getter.GetMemoryStore(); ms != nil {
if writer, ok := ms.(interface{ AppendCommunications(string) error }); ok {
err := writer.AppendCommunications(fmt.Sprintf("WhatsApp Sync (%s) @ %s:\n%s", chatID, time.Now().Format("2006-01-02 15:04"), resultText))
if err != nil {
return ErrorResult(fmt.Sprintf("Failed to sync to memory: %v", err))
}
return SilentResult("WhatsApp chat history synced to contextual memory.")
}
}
}
return ErrorResult("Memory store not available for sync.")
}
return SilentResult(resultText)
}

104
pkg/voice/synthesizer.go Normal file
View file

@ -0,0 +1,104 @@
package voice
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/sipeed/picoclaw/pkg/logger"
)
// Synthesizer is an interface for text-to-speech services.
type Synthesizer interface {
Name() string
Synthesize(ctx context.Context, text string) ([]byte, error)
}
// ElevenLabsSynthesizer implements Synthesizer using ElevenLabs API.
type ElevenLabsSynthesizer struct {
apiKey string
voiceID string
apiBase string
httpClient *http.Client
}
// NewElevenLabsSynthesizer creates a new ElevenLabsSynthesizer.
func NewElevenLabsSynthesizer(apiKey, voiceID string) *ElevenLabsSynthesizer {
if voiceID == "" {
voiceID = "EXAVITQu4vr4xnSDxMaL" // Default: Bella
}
return &ElevenLabsSynthesizer{
apiKey: apiKey,
voiceID: voiceID,
apiBase: "https://api.elevenlabs.io/v1",
httpClient: &http.Client{
Timeout: 60 * time.Second,
},
}
}
// Synthesize converts text to speech audio data (MP3).
func (s *ElevenLabsSynthesizer) Synthesize(ctx context.Context, text string) ([]byte, error) {
logger.InfoCF("voice", "Starting speech synthesis", map[string]any{
"provider": "elevenlabs",
"voice_id": s.voiceID,
"text_len": len(text),
})
url := fmt.Sprintf("%s/text-to-speech/%s", s.apiBase, s.voiceID)
requestBody, err := json.Marshal(map[string]any{
"text": text,
"model_id": "eleven_monolingual_v1",
"voice_settings": map[string]any{
"stability": 0.5,
"similarity_boost": 0.5,
},
})
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("xi-api-key", s.apiKey)
req.Header.Set("Accept", "audio/mpeg")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
logger.ErrorCF("voice", "ElevenLabs API error", map[string]any{
"status_code": resp.StatusCode,
"response": string(body),
})
return nil, fmt.Errorf("ElevenLabs API error (status %d): %s", resp.StatusCode, string(body))
}
audioData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
logger.InfoCF("voice", "Speech synthesis completed successfully", map[string]any{
"audio_size_bytes": len(audioData),
})
return audioData, nil
}
func (s *ElevenLabsSynthesizer) Name() string {
return "elevenlabs"
}