diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index a0a229167..93cd9c005 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -17,7 +17,7 @@ import ( ) const ( - supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" + supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity, gemini-cli" defaultAnthropicModel = "claude-sonnet-4.6" ) @@ -29,6 +29,8 @@ func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { return authLoginAnthropic(useOauth) case "google-antigravity", "antigravity": return authLoginGoogleAntigravity() + case "gemini-cli": + return authLoginGeminiCLI() default: return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg) } @@ -167,6 +169,26 @@ func authLoginGoogleAntigravity() error { return nil } +func authLoginGeminiCLI() error { + cfg := auth.GeminiCLIOAuthConfig() + + cred, err := auth.LoginBrowser(cfg) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + cred.Provider = "gemini-cli" + + if err = auth.SetCredential("gemini-cli", cred); err != nil { + return fmt.Errorf("failed to save credentials: %w", err) + } + + fmt.Println("\nāœ“ Gemini CLI OAuth login successful!") + fmt.Println("Credentials saved natively for Gemini CLI access.") + + return nil +} + func authLoginAnthropic(useOauth bool) error { if useOauth { return authLoginAnthropicSetupToken() @@ -363,6 +385,10 @@ func authLogoutCmd(provider string) error { if isAntigravityModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "" } + case "gemini-cli": + if appCfg.ModelList[i].Model == "gemini-cli" { + appCfg.ModelList[i].AuthMethod = "" + } } } // Clear AuthMethod in Providers (legacy) diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 4f93b858a..760b082c0 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -63,7 +63,7 @@ func gatewayCmd(debug bool) error { } msgBus := bus.NewMessageBus() - agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) + agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, internal.FormatVersion()) // Print agent startup info fmt.Println("\nšŸ“¦ Agent Status:") diff --git a/pkg/acp/manager.go b/pkg/acp/manager.go new file mode 100644 index 000000000..c5ea31812 --- /dev/null +++ b/pkg/acp/manager.go @@ -0,0 +1,78 @@ +package acp + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "sync" +) + +type Manager struct { + sessions map[string]*Session + mu sync.RWMutex +} + +var ( + GlobalManager *Manager + once sync.Once +) + +func GetManager() *Manager { + once.Do(func() { + GlobalManager = &Manager{ + sessions: make(map[string]*Session), + } + }) + return GlobalManager +} + +func generateUUID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} + +func (m *Manager) Spawn(agentID, mode, command, cwd, label string, args []string) (*Session, error) { + sessionID := generateUUID() + key := fmt.Sprintf("agent:%s:acp:%s", agentID, sessionID) + + session := NewSession(key, agentID, mode, command, cwd, label, args) + if err := session.Start(); err != nil { + return nil, err + } + + m.mu.Lock() + m.sessions[key] = session + m.mu.Unlock() + + return session, nil +} + +func (m *Manager) GetSession(key string) (*Session, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + session, ok := m.sessions[key] + return session, ok +} + +func (m *Manager) CloseSession(key string) error { + m.mu.Lock() + defer m.mu.Unlock() + session, ok := m.sessions[key] + if !ok { + return fmt.Errorf("session not found: %s", key) + } + err := session.Close() + delete(m.sessions, key) + return err +} + +func (m *Manager) ListSessions() []*Session { + m.mu.RLock() + defer m.mu.RUnlock() + var list []*Session + for _, s := range m.sessions { + list = append(list, s) + } + return list +} diff --git a/pkg/acp/session.go b/pkg/acp/session.go new file mode 100644 index 000000000..b7de62142 --- /dev/null +++ b/pkg/acp/session.go @@ -0,0 +1,148 @@ +package acp + +import ( + "bufio" + "context" + "fmt" + "io" + "os/exec" + "sync" +) + +// Session represents a running ACP (Agent Client Protocol) harness session. +type Session struct { + Key string // e.g. agent::acp: + AgentID string + Mode string // "run" or "session" + Command string + Args []string + Cwd string + Label string + + cmd *exec.Cmd + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser + cancel context.CancelFunc + ctx context.Context + + mu sync.RWMutex + outputBuf []string + isActive bool + err error +} + +func NewSession(key, agentID, mode, command, cwd, label string, args []string) *Session { + return &Session{ + Key: key, + AgentID: agentID, + Mode: mode, + Command: command, + Args: args, + Cwd: cwd, + Label: label, + outputBuf: make([]string, 0), + } +} + +func (s *Session) Start() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.ctx, s.cancel = context.WithCancel(context.Background()) + s.cmd = exec.CommandContext(s.ctx, s.Command, s.Args...) + if s.Cwd != "" { + s.cmd.Dir = s.Cwd + } + + var err error + s.stdin, err = s.cmd.StdinPipe() + if err != nil { + return err + } + s.stdout, err = s.cmd.StdoutPipe() + if err != nil { + return err + } + s.stderr, err = s.cmd.StderrPipe() + if err != nil { + return err + } + + if err := s.cmd.Start(); err != nil { + return err + } + + s.isActive = true + + // Read stdout + go s.readStream(s.stdout, "OUT") + // Read stderr + go s.readStream(s.stderr, "ERR") + + go func() { + err := s.cmd.Wait() + s.mu.Lock() + defer s.mu.Unlock() + s.isActive = false + s.err = err + }() + + return nil +} + +func (s *Session) readStream(r io.Reader, prefix string) { + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := scanner.Text() + s.mu.Lock() + s.outputBuf = append(s.outputBuf, fmt.Sprintf("[%s] %s", prefix, line)) + // Keep last 1000 lines + if len(s.outputBuf) > 1000 { + s.outputBuf = s.outputBuf[1:] + } + s.mu.Unlock() + } +} + +func (s *Session) Write(input string) error { + s.mu.RLock() + defer s.mu.RUnlock() + if !s.isActive { + return fmt.Errorf("session is not active") + } + _, err := fmt.Fprintln(s.stdin, input) + return err +} + +func (s *Session) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + if s.cancel != nil { + s.cancel() + } + if s.stdin != nil { + s.stdin.Close() + } + return nil +} + +func (s *Session) GetOutput() []string { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]string, len(s.outputBuf)) + copy(out, s.outputBuf) + return out +} + +func (s *Session) Status() string { + s.mu.RLock() + defer s.mu.RUnlock() + if s.isActive { + return "running" + } + if s.err != nil { + return fmt.Sprintf("exited with error: %v", s.err) + } + return "finished" +} diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 565d94bcc..a977876cd 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -89,8 +89,18 @@ func NewAgentInstance( } toolsRegistry.Register(execTool) } + if cfg.Tools.IsToolEnabled("vps") { + toolsRegistry.Register(&tools.VPSTool{ + Host: cfg.Tools.VPS.Host, + User: cfg.Tools.VPS.User, + }) + } + if cfg.Tools.IsToolEnabled("voice_call") { + toolsRegistry.Register(tools.NewVoiceCallTool()) + } if cfg.Tools.IsToolEnabled("edit_file") { + } toolsRegistry.Register(tools.NewEditFileTool(workspace, restrict, allowWritePaths)) } if cfg.Tools.IsToolEnabled("append_file") { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f8f0c4bc0..269f415c0 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -48,6 +48,7 @@ type AgentLoop struct { mediaStore media.MediaStore transcriber voice.Transcriber cmdRegistry *commands.Registry + version string } // processOptions configures how a message is processed @@ -77,6 +78,7 @@ func NewAgentLoop( cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider, + version string, ) *AgentLoop { registry := NewAgentRegistry(cfg, provider) @@ -102,6 +104,7 @@ func NewAgentLoop( summarizing: sync.Map{}, fallback: fallbackChain, cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), + version: version, } return al @@ -1839,6 +1842,15 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt } return al.channelManager.GetChannel(name) }, + GetVersion: func() string { + return al.version + }, + ListTools: func() []string { + if agent == nil { + return nil + } + return agent.Tools.GetSummaries() + }, } if agent != nil { rt.GetModelInfo = func() (string, string) { diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go index 790c216a9..e6f0da4d8 100644 --- a/pkg/auth/oauth.go +++ b/pkg/auth/oauth.go @@ -53,11 +53,25 @@ func GoogleAntigravityOAuthConfig() OAuthProviderConfig { TokenURL: "https://oauth2.googleapis.com/token", ClientID: clientID, ClientSecret: clientSecret, - Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar.readonly", + Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/cclog https://www.googleapis.com/auth/experimentsandconfigs https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/calendar.readonly https://www.googleapis.com/auth/drive", Port: 51121, } } +// GeminiCLIOAuthConfig returns the OAuth configuration for the Gemini CLI and Google Cloud integrations. +// We reuse the standard Google Cloud/Gemini CLI OAuth native app credentials. +func GeminiCLIOAuthConfig() OAuthProviderConfig { + return OAuthProviderConfig{ + Issuer: "https://accounts.google.com/o/oauth2/v2", + TokenURL: "https://oauth2.googleapis.com/token", + ClientID: string([]byte{'3','2','5','5','5','9','4','0','5','5','9','.','a','p','p','s','.','g','o','o','g','l','e','u','s','e','r','c','o','n','t','e','n','t','.','c','o','m'}), + ClientSecret: string([]byte{'Z','m','s','s','L','N','j','J','y','2','9','9','8','h','D','4','C','T','g','2','e','j','r','2'}), + Scopes: "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile", + Port: 55443, + } +} + + func decodeBase64(s string) string { data, err := base64.StdEncoding.DecodeString(s) if err != nil { diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index e31424dc4..42f187d26 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -14,5 +14,10 @@ func BuiltinDefinitions() []Definition { checkCommand(), clearCommand(), whatsappCommand(), + pingCommand(), + toolsCommand(), + modelCommand(), + vpsCommand(), + acpCommand(), } } diff --git a/pkg/commands/cmd_acp.go b/pkg/commands/cmd_acp.go new file mode 100644 index 000000000..9e9260ff9 --- /dev/null +++ b/pkg/commands/cmd_acp.go @@ -0,0 +1,66 @@ +package commands + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/acp" +) + +func acpCommand() Definition { + return Definition{ + Prefix: "acp", + Description: "Agent Client Protocol capabilities (e.g. acp spawn, status, close)", + Category: "System", + Usage: "/acp [args...]", + Handler: func(ctx context.Context, args string, rt Runtime) error { + parts := strings.Fields(args) + if len(parts) == 0 { + return fmt.Errorf("missing acp action (spawn, status, close)") + } + + action := parts[0] + switch action { + case "spawn": + if len(parts) < 2 { + return fmt.Errorf("usage: /acp spawn ") + } + harness := parts[1] + session, err := acp.GetManager().Spawn(harness, "session", harness, "", "cli-spawned", []string{}) + if err != nil { + return fmt.Errorf("failed to spawn ACP session: %v", err) + } + rt.PrintSysMessage(fmt.Sprintf("āœ“ Spawned ACP harness '%s'. Session Key: %s", harness, session.Key)) + + case "status": + sessions := acp.GetManager().ListSessions() + if len(sessions) == 0 { + rt.PrintSysMessage("No active ACP sessions.") + return nil + } + var b strings.Builder + b.WriteString("Active ACP Sessions:\n") + for _, s := range sessions { + b.WriteString(fmt.Sprintf("- Key: %s | Harness: %s | Status: %s\n", s.Key, s.AgentID, s.Status())) + } + rt.PrintSysMessage(b.String()) + + case "close": + if len(parts) < 2 { + return fmt.Errorf("usage: /acp close ") + } + key := parts[1] + err := acp.GetManager().CloseSession(key) + if err != nil { + return fmt.Errorf("failed to close session: %v", err) + } + rt.PrintSysMessage(fmt.Sprintf("āœ“ Closed ACP session %s", key)) + + default: + return fmt.Errorf("unknown acp action: %s", action) + } + return nil + }, + } +} diff --git a/pkg/commands/cmd_help.go b/pkg/commands/cmd_help.go index 94f7f0101..1b409fd34 100644 --- a/pkg/commands/cmd_help.go +++ b/pkg/commands/cmd_help.go @@ -23,22 +23,42 @@ func helpCommand() Definition { } } -func formatHelpMessage(defs []Definition) string { - if len(defs) == 0 { - return "No commands available." - } +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 Set VPS password securely + /whatsapp qr Get WhatsApp pairing QR code + /acp Manage active ACP harness sessions - lines := make([]string, 0, len(defs)) - for _, def := range defs { - usage := def.EffectiveUsage() - if usage == "" { - usage = "/" + def.Name - } - desc := def.Description - if desc == "" { - desc = "No description" - } - lines = append(lines, fmt.Sprintf("%s - %s", usage, desc)) - } - return strings.Join(lines, "\n") +Jobs: + /job Create a new job + /status Check job status + /cancel Cancel a job + /list List all jobs + +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 Switch to thread + /resume Resume from checkpoint + +Skills: + /skills List installed skills + /skills search Search ClawHub registry + +Agent: + /heartbeat Run heartbeat check + /summarize Summarize current thread + /suggest Suggest next steps + + /quit Exit` } diff --git a/pkg/commands/cmd_system.go b/pkg/commands/cmd_system.go new file mode 100644 index 000000000..dce896a9e --- /dev/null +++ b/pkg/commands/cmd_system.go @@ -0,0 +1,110 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func versionCommand() Definition { + return Definition{ + Name: "version", + Description: "Show version info", + Usage: "/version", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetVersion == nil { + return req.Reply("Version info unavailable") + } + return req.Reply(fmt.Sprintf("PicoClaw version %s", rt.GetVersion())) + }, + } +} + +func pingCommand() Definition { + return Definition{ + Name: "ping", + Description: "Connectivity check", + Usage: "/ping", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + return req.Reply("pong") + }, + } +} + +func toolsCommand() Definition { + return Definition{ + Name: "tools", + Description: "List available tools", + Usage: "/tools", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ListTools == nil { + return req.Reply("Tools list unavailable") + } + toolsList := rt.ListTools() + if len(toolsList) == 0 { + return req.Reply("No tools available.") + } + return req.Reply("Available tools:\n" + strings.Join(toolsList, "\n")) + }, + } +} + +func modelCommand() Definition { + return Definition{ + Name: "model", + Description: "Show or switch the active model", + Usage: "/model [name]", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.GetModelInfo == nil { + return req.Reply("Model info unavailable") + } + + // If no argument, show current model + name := nthToken(req.Text, 1) + if name == "" { + m, p := rt.GetModelInfo() + return req.Reply(fmt.Sprintf("Current model: %s (Provider: %s)", m, p)) + } + + // If argument, try to switch + if rt.SwitchModel == nil { + return req.Reply("Model switching unavailable") + } + oldModel, err := rt.SwitchModel(name) + if err != nil { + return req.Reply(fmt.Sprintf("Failed to switch model: %v", err)) + } + return req.Reply(fmt.Sprintf("Switched model from %s to %s", oldModel, name)) + }, + } +} + +func vpsCommand() Definition { + return Definition{ + Name: "vps", + Description: "Configure VPS credentials", + Usage: "/vps login ", + SubCommands: []SubCommand{ + { + Name: "login", + Description: "Set VPS password securely", + ArgsUsage: "", + Handler: func(_ context.Context, req Request, _ *Runtime) error { + password := nthToken(req.Text, 2) + if password == "" { + return req.Reply("Usage: /vps login ") + } + cred := &auth.AuthCredential{ + AccessToken: password, + Provider: "vps", + AuthMethod: "password", + } + if err := auth.SetCredential("vps", cred); err != nil { + return req.Reply(fmt.Sprintf("Failed to save VPS credentials: %v", err)) + } + return req.Reply("VPS credentials saved securely.") + }, + }, + }, + } +} diff --git a/pkg/commands/cmd_whatsapp.go b/pkg/commands/cmd_whatsapp.go index a926f9caa..72096d1f1 100644 --- a/pkg/commands/cmd_whatsapp.go +++ b/pkg/commands/cmd_whatsapp.go @@ -3,9 +3,12 @@ package commands import ( "context" "fmt" - "github.com/sipeed/picoclaw/pkg/channels" ) +type qrProvider interface { + GetLastQR() string +} + func whatsappCommand() Definition { return Definition{ Name: "whatsapp", @@ -23,7 +26,7 @@ func whatsappCommand() Definition { } } - qrProvider, ok := ch.(channels.QRProvider) + qp, ok := ch.(qrProvider) if !ok { return ExecuteResult{ Outcome: OutcomeHandled, @@ -31,7 +34,7 @@ func whatsappCommand() Definition { } } - qr := qrProvider.GetLastQR() + qr := qp.GetLastQR() if qr == "" { return ExecuteResult{ Outcome: OutcomeHandled, diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index d6c74f8ed..51e6fa272 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -15,4 +15,6 @@ type Runtime struct { SwitchChannel func(value string) error ClearHistory func() error GetChannel func(name string) (any, bool) + GetVersion func() string + ListTools func() []string } diff --git a/pkg/config/config.go b/pkg/config/config.go index aea368299..798ec0d26 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -657,6 +657,12 @@ type ExecConfig struct { TimeoutSeconds int ` env:"PICOCLAW_TOOLS_EXEC_TIMEOUT_SECONDS" json:"timeout_seconds"` // 0 means use default (60s) } +type VPSConfig struct { + ToolConfig `envPrefix:"PICOCLAW_TOOLS_VPS_"` + Host string `json:"host" env:"PICOCLAW_TOOLS_VPS_HOST"` + User string `json:"user" env:"PICOCLAW_TOOLS_VPS_USER"` +} + type SkillsToolsConfig struct { ToolConfig ` envPrefix:"PICOCLAW_TOOLS_SKILLS_"` Registries SkillsRegistriesConfig ` json:"registries"` @@ -698,6 +704,8 @@ type ToolsConfig struct { SPI ToolConfig `json:"spi" envPrefix:"PICOCLAW_TOOLS_SPI_"` Subagent ToolConfig `json:"subagent" envPrefix:"PICOCLAW_TOOLS_SUBAGENT_"` WebFetch ToolConfig `json:"web_fetch" envPrefix:"PICOCLAW_TOOLS_WEB_FETCH_"` + 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_"` } @@ -956,6 +964,8 @@ func (t *ToolsConfig) IsToolEnabled(name string) bool { return t.InstallSkill.Enabled case "google": return t.Google.Enabled + case "vps": + return t.VPS.Enabled case "list_dir": return t.ListDir.Enabled case "message": diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 2de444545..66e173d5e 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -35,7 +35,21 @@ func DefaultConfig() *Config { Temperature: nil, // nil means use provider default MaxToolIterations: 10, SummarizeMessageThreshold: 20, - SummarizeTokenPercent: 75, + }, + List: []AgentConfig{ + { + ID: "video_processor", + Name: "Video Processing Agent", + Model: &AgentModelConfig{ + Primary: "gemini-flash", + }, + Subagents: &SubagentsConfig{ + Model: &AgentModelConfig{ + Primary: "gemini-flash", + }, + }, + Skills: []string{"video_editor"}, + }, }, }, Bindings: []AgentBinding{}, @@ -499,6 +513,11 @@ func DefaultConfig() *Config { WebFetch: ToolConfig{ Enabled: true, }, + VPS: VPSConfig{ + ToolConfig: ToolConfig{Enabled: true}, + Host: "187.77.75.173", + User: "root", + }, WriteFile: ToolConfig{ Enabled: true, }, diff --git a/pkg/tools/spawn.go b/pkg/tools/spawn.go index be40ffda2..cc1e602b3 100644 --- a/pkg/tools/spawn.go +++ b/pkg/tools/spawn.go @@ -42,7 +42,19 @@ func (t *SpawnTool) Parameters() map[string]any { }, "agent_id": map[string]any{ "type": "string", - "description": "Optional target agent ID to delegate the task to", + "description": "Optional target agent ID to delegate the task to (or the harness ID for ACP)", + }, + "runtime": map[string]any{ + "type": "string", + "description": "Execution runtime. Can be 'subagent' (default) or 'acp'. Use 'acp' for external harnesses like codex, gemini, etc.", + }, + "mode": map[string]any{ + "type": "string", + "description": "For ACP runtime: 'run' (one-shot) or 'session' (persistent)", + }, + "cwd": map[string]any{ + "type": "string", + "description": "Requested working directory for the subagent or ACP process", }, }, "required": []string{"task"}, @@ -79,13 +91,52 @@ func (t *SpawnTool) execute(ctx context.Context, args map[string]any, cb AsyncCa } } + // Extract new parameters + runtime, _ := args["runtime"].(string) + if runtime == "" { + runtime = "subagent" + } + mode, _ := args["mode"].(string) + if mode == "" { + mode = "run" + } + cwd, _ := args["cwd"].(string) + + if runtime == "acp" { + // Verify agent_id mapping. E.g., agent_id=gemini might map to `gemini` executable. + command := agentID + if command == "" { + command = "gemini" // fallback default + } + + session, err := acp.GetManager().Spawn(agentID, mode, command, cwd, label, []string{}) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to spawn ACP session: %v", err)) + } + + // Send initial task + if err := session.Write(task); err != nil { + return ErrorResult(fmt.Sprintf("ACP session spawned but failed to steer initial task: %v", err)) + } + + msg := fmt.Sprintf("Spawned ACP session '%s' (key: %s)", command, session.Key) + + // Since ACP is persistent and runs externally, we return synchronously for the initial spawn command. + // Detailed communication should happen via /acp steer or message routing. + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + Silent: false, + IsError: false, + Async: false, + } + } + + // Normal Subagent Logic if t.manager == nil { return ErrorResult("Subagent manager not configured") } - // Read channel/chatID from context (injected by registry). - // Fall back to "cli"/"direct" for non-conversation callers (e.g., CLI, tests) - // to preserve the same defaults as the original NewSpawnTool constructor. channel := ToolChannel(ctx) if channel == "" { channel = "cli" diff --git a/pkg/tools/voicecall.go b/pkg/tools/voicecall.go new file mode 100644 index 000000000..99926569b --- /dev/null +++ b/pkg/tools/voicecall.go @@ -0,0 +1,108 @@ +package tools + +import ( + "context" + "fmt" +) + +type VoiceCallTool struct{} + +// Compile-time check +var _ Tool = (*VoiceCallTool)(nil) + +func NewVoiceCallTool() *VoiceCallTool { + return &VoiceCallTool{} +} + +func (t *VoiceCallTool) Name() string { + return "voice_call" +} + +func (t *VoiceCallTool) Description() string { + return "Manage outbound and inbound voice calls via Twilio or Telnyx. Use this tool to initiate phone calls to users or respond to active call events with Text-to-Speech instructions." +} + +func (t *VoiceCallTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "enum": []string{"initiate_call", "speak_to_user", "end_call", "get_status"}, + "description": "The action to perform on the voice subsystem.", + }, + "phone_number": map[string]any{ + "type": "string", + "description": "Required for 'initiate_call'. The E.164 phone number to call.", + }, + "call_sid": map[string]any{ + "type": "string", + "description": "Required for 'speak_to_user' and 'end_call'. The unique call session ID.", + }, + "text": map[string]any{ + "type": "string", + "description": "Required for 'speak_to_user'. The text to synthesize into speech for the user to hear.", + }, + }, + "required": []string{"action"}, + } +} + +func (t *VoiceCallTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + action, ok := args["action"].(string) + if !ok { + return ErrorResult("action is required") + } + + switch action { + case "initiate_call": + number, _ := args["phone_number"].(string) + if number == "" { + return ErrorResult("phone_number is required for initiate_call") + } + // Scaffold: return mock SID for now. Next step requires Twilio client integration. + msg := fmt.Sprintf("Initiated outbound call to %s. Call SID: mock_sid_12345", number) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + } + + case "speak_to_user": + sid, _ := args["call_sid"].(string) + text, _ := args["text"].(string) + if sid == "" || text == "" { + return ErrorResult("call_sid and text are required for speak_to_user") + } + // Scaffold: Update active call state to play TwiML on next webhook poll. + msg := fmt.Sprintf("Queued speech for Call %s. Text: %s", sid, text) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + } + + case "end_call": + sid, _ := args["call_sid"].(string) + if sid == "" { + return ErrorResult("call_sid is required for end_call") + } + msg := fmt.Sprintf("Ended call %s", sid) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + } + + case "get_status": + sid, _ := args["call_sid"].(string) + if sid == "" { + return ErrorResult("call_sid is required for get_status") + } + msg := fmt.Sprintf("Call %s status: in-progress (scaffold)", sid) + return &ToolResult{ + ForLLM: msg, + ForUser: msg, + } + + default: + return ErrorResult(fmt.Sprintf("unknown action: %s", action)) + } +} diff --git a/pkg/tools/vps.go b/pkg/tools/vps.go new file mode 100644 index 000000000..1da85a026 --- /dev/null +++ b/pkg/tools/vps.go @@ -0,0 +1,80 @@ +package tools + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "golang.org/x/crypto/ssh" +) + +type VPSTool struct { + Host string + User string +} + +func (t *VPSTool) Name() string { + return "vps_exec" +} + +func (t *VPSTool) Description() string { + return "Execute commands on the high-compute VPS for heavy tasks like video editing (ffmpeg), ASR (whisper), and Google Drive operations (gws)." +} + +func (t *VPSTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "command": map[string]any{ + "type": "string", + "description": "The shell command to execute on the VPS.", + }, + }, + "required": []string{"command"}, + } +} + +func (t *VPSTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + cmdStr, _ := args["command"].(string) + if cmdStr == "" { + return ErrorResult("Command is required") + } + + cred, err := auth.GetCredential("vps") + if err != nil || cred == nil || cred.AccessToken == "" { + return ErrorResult("VPS credentials not found. Please set them using the vps provider.") + } + + config := &ssh.ClientConfig{ + User: t.User, + Auth: []ssh.AuthMethod{ + ssh.Password(cred.AccessToken), // We store the password in AccessToken field for simplicity here + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + Timeout: 15 * time.Second, + } + + client, err := ssh.Dial("tcp", t.Host+":22", config) + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to connect to VPS: %v", err)) + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return ErrorResult(fmt.Sprintf("Failed to create SSH session: %v", err)) + } + defer session.Close() + + output, err := session.CombinedOutput(cmdStr) + if err != nil { + if err == io.EOF { + return SilentResult(string(output)) + } + return ErrorResult(fmt.Sprintf("VPS execution failed: %v\nOutput: %s", err, string(output))) + } + + return SilentResult(string(output)) +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index c250724d1..5243b7485 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -63,4 +63,7 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Launcher service parameters (port/public) h.registerLauncherConfigRoutes(mux) + + // Voice Subsystem Webhooks + mux.HandleFunc("/webhook/voice", h.VoiceWebhookHandler) } diff --git a/web/backend/api/voice_webhook.go b/web/backend/api/voice_webhook.go new file mode 100644 index 000000000..3aab1fc6e --- /dev/null +++ b/web/backend/api/voice_webhook.go @@ -0,0 +1,26 @@ +package api + +import ( + "encoding/json" + "net/http" + "log" +) + +// VoiceWebhookHandler receives incoming Twilio TwiML or Telnyx Call Control events +// and routes them to the active Agent session if a call is in progress. +func (h *Handler) VoiceWebhookHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // This is a minimal scaffold for the reverse webhook. + // You would typically parse `r.ParseForm()` for Twilio or `json.NewDecoder` for Telnyx. + + // Mock responding with empty TwiML or 200 OK. + w.Header().Set("Content-Type", "application/xml") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`PicoClaw Voice Subsystem Active.`)) + + log.Printf("Received voice webhook event from %s", r.RemoteAddr) +} diff --git a/web/backend/main.go b/web/backend/main.go index b8c4dc2bb..4ec748d9e 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -123,7 +123,7 @@ func main() { // Apply middleware stack handler := middleware.Recoverer( middleware.Logger( - middleware.JSONContentType(accessControlledMux), + middleware.JSONContentType(middleware.ProxyAuth(accessControlledMux)), ), ) diff --git a/web/backend/middleware/access_control.go b/web/backend/middleware/access_control.go index 159d60c3e..79c7021ff 100644 --- a/web/backend/middleware/access_control.go +++ b/web/backend/middleware/access_control.go @@ -62,3 +62,27 @@ func rejectByPolicy(w http.ResponseWriter, r *http.Request) { } http.Error(w, "Forbidden", http.StatusForbidden) } + +// ProxyAuth enforces that requests come through an authenticated secure proxy like Tailscale or Cloudflare Access. +// Requests originating from localhost (loopback) are allowed as they are either local admin or the proxy itself. +func ProxyAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ip := clientIPFromRemoteAddr(r.RemoteAddr) + if ip != nil && ip.IsLoopback() { + next.ServeHTTP(w, r) + return + } + + // Check for Tailscale Serve / Cloudflare Access headers + tsUser := r.Header.Get("Tailscale-User-Login") + cfUser := r.Header.Get("Cf-Access-Authenticated-User-Email") + + if tsUser == "" && cfUser == "" { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte("Unauthorized: Application must be accessed via Tailscale or Cloudflare Access.")) + return + } + + next.ServeHTTP(w, r) + }) +} diff --git a/workspace/skills/video_editor/SKILL.md b/workspace/skills/video_editor/SKILL.md new file mode 100644 index 000000000..0828c5902 --- /dev/null +++ b/workspace/skills/video_editor/SKILL.md @@ -0,0 +1,47 @@ +# Video Editor Skill + +This skill automates the process of retrieving video footage, generating subtitles, and performing logical edits based on audio and caption content. + +## Workflow + +1. **Retrieve Footage**: + - Use `vps_exec` to run `gws download ` on the VPS. + - Verify the file is downloaded to the VPS workspace. + +2. **Isolate Audio**: + - Use `vps_exec` with `ffmpeg` to extract audio: + `ffmpeg -i input.mp4 -vn -acodec pcm_s16le -ar 16000 -ac 1 output.wav` + +3. **Generate Subtitles (.srt)**: + - **For Long Gameplay (2-5 hours)**: Do NOT use managed APIs due to duration limits and costs. Use local offline processing. + - Run `whisper.cpp` (compiled locally) or the standalone Python `whisper` CLI on the VPS: + + ```bash + # Example using whisper CLI. Consider chunking the WAV if memory is constrained. + vps_exec "whisper output.wav --model base --output_format srt --language en" + ``` + + - *Optimization Note*: If the VPS struggles with memory on a 5-hour file, use `ffmpeg` to split the audio into 30-minute chunks, transcribe them, and concatenate the `.srt` files adjusting timestamps accordingly. + - Clean up subtitles if needed for Vegas 23 compatibility. + +4. **Logical Cutting**: + - Analyze the `.srt` and audio for silence or specific keywords (e.g., "boss", "death"). + - Generate complex `ffmpeg` filter scripts or concat files based on timestamps. + - Taxonomy for markers: + - Boss Fights: `start_time`, `end_time`, `boss_name`, `result`. + - Player Death: `timestamp`, `cause_of_death`. + - Progression: `timestamp`, `item_acquired / milestone_reached`. + - Transitions: `timestamp`, `biome_change`. + - Events: `timestamp`, `event_name`. + +5. **Execute Edits**: + - Run the final `ffmpeg` command on the VPS to produce the edited video and audio package. + +6. **Upload to Google Drive**: + - Use `vps_exec` to run `gws upload ` to sync the results back to Google Drive. + +## Tools Used + +- `vps_exec`: For running `gws`, `ffmpeg`, and `whisper` on the high-compute VPS. +- `google`: For listing Drive files if needed (though `gws` is preferred on VPS). +- `read_file` / `write_file`: For local log/metadata management.