From 0eccc5f89f08d5c63fe92c6ed5c418b15f911c75 Mon Sep 17 00:00:00 2001 From: Bernardo Date: Sat, 14 Mar 2026 11:53:48 +0100 Subject: [PATCH] Backup Orange Pi live worktree with routing, preview, and GWS fixes --- cmd/picoclaw/internal/gateway/helpers.go | 21 + cmd/picoclaw/internal/skills/helpers.go | 25 +- pkg/agent/context.go | 171 +- pkg/agent/context_capability_test.go | 33 + pkg/agent/instance.go | 113 +- pkg/agent/loop.go | 1880 +++++-- pkg/agent/loop_media.go | 42 + pkg/agent/loop_test.go | 40 + pkg/agent/loop_toolcapable_test.go | 39 + pkg/agent/profile_workspace.go | 232 + pkg/agent/registry.go | 21 +- pkg/channels/control_plane_dashboard.go | 4327 +++++++++++++++++ pkg/channels/manager.go | 510 +- pkg/channels/telegram/telegram.go | 392 +- .../whatsapp_native/whatsapp_native.go | 12 +- pkg/commands/builtin.go | 2 + pkg/commands/cmd_check.go | 73 +- pkg/commands/cmd_clear.go | 4 +- pkg/commands/cmd_exec.go | 14 + pkg/commands/cmd_help.go | 11 +- pkg/commands/cmd_run.go | 47 + pkg/commands/cmd_show.go | 92 +- pkg/commands/cmd_switch.go | 80 +- pkg/commands/executor.go | 4 + pkg/commands/runtime.go | 16 +- pkg/config/config.go | 1 + pkg/providers/openai_compat/provider.go | 75 +- pkg/providers/openai_compat/provider_test.go | 50 + pkg/skills/install_check.go | 149 + pkg/skills/registry.go | 6 +- pkg/tools/cron.go | 10 +- pkg/tools/filesystem.go | 214 + pkg/tools/filesystem_test.go | 53 + pkg/tools/message.go | 19 +- pkg/tools/preview.go | 196 + pkg/tools/preview_test.go | 61 + pkg/tools/shell.go | 95 +- pkg/tools/shell_test.go | 49 + pkg/tools/skills_install.go | 13 +- pkg/tools/write_pdf.go | 139 + pkg/voice/synthesizer.go | 280 ++ pkg/voice/transcriber.go | 470 +- pkg/voice/transcriber_test.go | 186 +- 43 files changed, 9643 insertions(+), 624 deletions(-) create mode 100644 pkg/agent/context_capability_test.go create mode 100644 pkg/agent/loop_toolcapable_test.go create mode 100644 pkg/agent/profile_workspace.go create mode 100644 pkg/channels/control_plane_dashboard.go create mode 100644 pkg/commands/cmd_exec.go create mode 100644 pkg/commands/cmd_run.go create mode 100644 pkg/skills/install_check.go create mode 100644 pkg/tools/preview.go create mode 100644 pkg/tools/preview_test.go create mode 100644 pkg/tools/write_pdf.go create mode 100644 pkg/voice/synthesizer.go diff --git a/cmd/picoclaw/internal/gateway/helpers.go b/cmd/picoclaw/internal/gateway/helpers.go index 4f93b858a..eebbed43e 100644 --- a/cmd/picoclaw/internal/gateway/helpers.go +++ b/cmd/picoclaw/internal/gateway/helpers.go @@ -132,16 +132,35 @@ func gatewayCmd(debug bool) error { mediaStore.Stop() return fmt.Errorf("error creating channel manager: %w", err) } + channelManager.SetControlPlaneDiagnoser(func(ctx context.Context, prompt string) (string, error) { + return agentLoop.ProcessHeartbeat(ctx, prompt, "cli", "direct") + }) // Inject channel manager and media store into agent loop agentLoop.SetChannelManager(channelManager) agentLoop.SetMediaStore(mediaStore) + previewRestrict := cfg.Agents.Defaults.RestrictToWorkspace && !cfg.Agents.Defaults.AllowReadOutsideWorkspace + agentLoop.RegisterPerAgentTool(func(agentID string, instance *agent.AgentInstance) tools.Tool { + _ = agentID + return tools.NewHostPreviewTool(instance.Workspace, previewRestrict, func(root, entry, slug string) (*tools.HostedPreview, error) { + actualSlug, tailscaleURL, localURL, err := channelManager.PublishPreview(root, entry, slug) + if err != nil { + return nil, err + } + return &tools.HostedPreview{Slug: actualSlug, Root: root, Entry: entry, TailscaleURL: tailscaleURL, LocalURL: localURL}, nil + }) + }) + // Wire up voice transcription if a supported provider is configured. if transcriber := voice.DetectTranscriber(cfg); transcriber != nil { agentLoop.SetTranscriber(transcriber) logger.InfoCF("voice", "Transcription enabled (agent-level)", map[string]any{"provider": transcriber.Name()}) } + if synthesizer := voice.DetectSynthesizer(cfg); synthesizer != nil { + agentLoop.SetSynthesizer(synthesizer) + logger.InfoCF("voice", "Speech synthesis enabled (agent-level)", map[string]any{"provider": synthesizer.Name()}) + } enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { @@ -188,6 +207,8 @@ func gatewayCmd(debug bool) error { return err } + // Shared HTTP server mode never calls health.Server.Start(), so mark readiness explicitly. + healthServer.SetReady(true) fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) go agentLoop.Run(ctx) diff --git a/cmd/picoclaw/internal/skills/helpers.go b/cmd/picoclaw/internal/skills/helpers.go index a59a2013a..1a95dba7b 100644 --- a/cmd/picoclaw/internal/skills/helpers.go +++ b/cmd/picoclaw/internal/skills/helpers.go @@ -36,18 +36,9 @@ func skillsListCmd(loader *skills.SkillsLoader) { } func skillsInstallCmd(installer *skills.SkillInstaller, repo string) error { - fmt.Printf("Installing skill from %s...\n", repo) - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if err := installer.InstallFromGitHub(ctx, repo); err != nil { - return fmt.Errorf("failed to install skill: %w", err) - } - - fmt.Printf("\u2713 Skill '%s' installed successfully!\n", filepath.Base(repo)) - - return nil + _ = installer + _ = repo + return fmt.Errorf("direct repository skill installs are disabled on this node; use `picoclaw skills install --registry clawhub `") } // skillsInstallFromRegistry installs a skill from a named registry (e.g. clawhub). @@ -110,6 +101,16 @@ func skillsInstallFromRegistry(cfg *config.Config, registryName, slug string) er fmt.Printf("\u26a0\ufe0f Warning: skill '%s' is flagged as suspicious.\n", slug) } + check, err := skills.VerifyInstalledSkill(targetDir, registry.Name(), slug, result.Version) + if err != nil { + _ = os.RemoveAll(targetDir) + return fmt.Errorf("✗ local verification failed: %w", err) + } + if !check.Passed { + _ = os.RemoveAll(targetDir) + return fmt.Errorf("✗ local verification blocked install: %s", strings.Join(check.FailureReasons, "; ")) + } + fmt.Printf("\u2713 Skill '%s' v%s installed successfully!\n", slug, result.Version) if result.Summary != "" { fmt.Printf(" %s\n", result.Summary) diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 719b0cb6d..384a68c26 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -1,10 +1,12 @@ package agent import ( + "encoding/json" "errors" "fmt" "io/fs" "os" + "os/exec" "path/filepath" "runtime" "slices" @@ -78,20 +80,101 @@ You are picoclaw, a helpful AI assistant. ## Workspace Your workspace is at: %s +- Profile: %s/PROFILE.json - Memory: %s/memory/MEMORY.md - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md - Skills: %s/skills/{skill-name}/SKILL.md ## Important Rules -1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. +1. **ALWAYS use tools for actions** - When you need to perform an action (schedule reminders, send messages, execute commands, install something, modify files, verify status, restart services, scan for issues, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it. -2. **Be helpful and accurate** - When using tools, briefly explain what you're doing. +2. **No fabricated completion** - Never claim something was completed, verified, installed, restarted, scanned, checked, or fixed unless a tool call in this turn actually succeeded. If you did not run the tool or the result is incomplete, say that plainly. -3. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md +3. **Evidence-first reporting** - For security, integrity, or operational claims, cite the concrete evidence briefly: tool name, command, file, or decisive output. If you cannot point to evidence, say not verified instead of guessing. -4. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, - workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) +4. **No fictional tool narratives** - Do not invent command output, hashes, scans, health checks, or summaries of work you did not actually perform. If a tool failed or was blocked, report the exact blocker and the next safe step. + +5. **Use an operator voice** - Be direct, concise, and technically specific. Avoid padded summaries, fake enthusiasm, robotic filler, and broad claims like all systems operational unless you have explicit evidence. + +6. **Skill/install discipline** - Prefer existing tools and workspace files first. Do not recommend or install a new skill unless the user explicitly asked for it or current tools cannot do the job. When installing, state the source, trust status, and whether local verification passed. + +7. **Memory** - When interacting with me if something seems memorable, update %s/memory/MEMORY.md + +8. **Persist until complete** - For build, fix, recovery, automation, and ops tasks, keep working until the task is actually complete or you hit a concrete external blocker. Use tools, verify results, retry sensible fixes, and self-correct before asking the user to do more. + +9. **Question when needed** - If the next safe step depends on a missing detail or user approval, ask one concise question in chat instead of denying the capability or inventing limitations. + +10. **Context summaries** - Conversation summaries provided as context are approximate references only. They may be incomplete or outdated. Always defer to explicit user instructions over summary content.`, + workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) +} + +func detectMCPContext() string { + type mcpProbe struct { + Tools struct { + MCP struct { + Enabled bool `json:"enabled"` + Servers map[string]json.RawMessage `json:"servers"` + } `json:"mcp"` + } `json:"tools"` + } + + cfgPath := filepath.Join(getGlobalConfigDir(), "config.json") + data, err := os.ReadFile(cfgPath) + if err != nil || len(data) == 0 { + return "" + } + + var probe mcpProbe + if err := json.Unmarshal(data, &probe); err != nil { + return "" + } + if !probe.Tools.MCP.Enabled { + return "MCP integration is disabled in config. Do not claim MCP-backed capabilities unless it is re-enabled." + } + if len(probe.Tools.MCP.Servers) == 0 { + return "No MCP servers are configured right now. Use local tools and skills instead of inventing MCP-backed access." + } + + names := make([]string, 0, len(probe.Tools.MCP.Servers)) + for name := range probe.Tools.MCP.Servers { + names = append(names, name) + } + slices.Sort(names) + return fmt.Sprintf("Configured MCP servers: %s. Use their tools when relevant, but only after confirming the tools are actually registered in this runtime.", strings.Join(names, ", ")) +} + +func (cb *ContextBuilder) buildCapabilityPrimer() string { + skillCount := 0 + if cb.skillsLoader != nil { + skillCount = len(cb.skillsLoader.ListSkills()) + } + + parts := []string{ + "# Capability Use", + "Tools are executable actions. Use them whenever you need to inspect, modify, install, test, restart, send, or verify something.", + "Skills are local instruction files. When a task matches a skill, read the relevant SKILL.md with the read_file tool before acting.", + fmt.Sprintf("Discovered skills: %d", skillCount), + fmt.Sprintf("Skill roots: %s", strings.Join(cb.skillRoots(), ", ")), + "MCP tools come from configured MCP servers. Use them when present, but never invent MCP-backed abilities when no server or tool is available.", + "In natural-language chat, translate intent into the right tool, skill, or MCP workflow yourself instead of asking the user to restate it as slash commands.", + "When stuck, inspect tool errors, logs, config, help output, and skill docs first. After a durable fix or a recurring blocker, write a short note to memory/MEMORY.md so later turns improve.", + "For attached PDFs and file translation requests, use read_file on the actual file path or media path first. read_file can extract PDF text and fall back to OCR.", + "Translation, summarization, and rewriting are normal model tasks after the file content is read. They do not require a separate translation tool.", + "If the user says to Romanian, to ro, summarize this PDF, or similar, read the attachment first and then answer directly from the extracted text.", + "If PDF extraction fails because a system dependency is missing, use exec to repair it in-chat, for example by installing poppler-utils or checking tesseract, then retry the read.", + "If the message already contains Voice note transcript:, treat that transcript as authoritative input. Do not propose Whisper setup, package installs, or API-key-based retranscription unless the user explicitly asks for a different transcription path.", + "When building a site/app that needs review, use the host_preview tool on the project directory or built output and give the user the returned Tailscale/local preview URL.", + "Keep the same preview slug when iterating so the URL stays stable across fixes.", + "If the user sends a screenshot or annotated image of the preview, inspect it as UI feedback, apply the corrections in code, and refresh the hosted preview instead of asking them to describe the screenshot in text.", + "If the user asks for PDF export, use write_pdf to generate the PDF and send_file to deliver it back in chat instead of stalling on manual conversion steps.", + "If direct file delivery in chat is unavailable or keeps failing, upload the generated file with gws drive files create --upload and make it readable with gws drive permissions create, then return the shareable Drive link.", + "Keep user-facing status updates, explanations, and confirmations in English unless the user explicitly asks for another reply language. The translated or exported document content itself can be in the requested target language.", + } + if mcpCtx := detectMCPContext(); mcpCtx != "" { + parts = append(parts, mcpCtx) + } + return strings.Join(parts, "\n") } func (cb *ContextBuilder) BuildSystemPrompt() string { @@ -99,6 +182,7 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { // Core identity section parts = append(parts, cb.getIdentity()) + parts = append(parts, cb.buildCapabilityPrimer()) // Bootstrap files bootstrapContent := cb.LoadBootstrapFiles() @@ -193,6 +277,7 @@ func (cb *ContextBuilder) sourcePaths() []string { filepath.Join(cb.workspace, "SOUL.md"), filepath.Join(cb.workspace, "USER.md"), filepath.Join(cb.workspace, "IDENTITY.md"), + filepath.Join(cb.workspace, "PROFILE.json"), filepath.Join(cb.workspace, "memory", "MEMORY.md"), } } @@ -403,6 +488,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { "SOUL.md", "USER.md", "IDENTITY.md", + "PROFILE.json", } var sb strings.Builder @@ -424,6 +510,73 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { // // See: https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching // See: https://platform.openai.com/docs/guides/prompt-caching +func detectGoogleWorkspaceContext() string { + gwsPath, err := exec.LookPath("gws") + if err != nil || strings.TrimSpace(gwsPath) == "" { + return "" + } + + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return "" + } + + credsPath := filepath.Join(home, ".config", "gws", "credentials.json") + if _, err := os.Stat(credsPath); err != nil { + return "" + } + + return strings.Join([]string{ + "## Connected Integrations", + fmt.Sprintf("Google Workspace CLI is available at %s and credentials are present at %s.", gwsPath, credsPath), + "For Gmail/email/inbox/mail requests, use the exec tool with gws commands instead of saying email access is unavailable.", + "Examples:", + "- recent unread mail: gws gmail +triage --max 5 --format table", + "- search mail: gws gmail +triage --max 5 --query 'from:alice' --format table", + "- read one message by id: gws gmail users messages get --params '{\"userId\":\"me\",\"id\":\"\",\"format\":\"full\"}' --format json", + "- one-shot inbox watch: gws gmail +watch --label-ids INBOX --once --max-messages 5 --format json", + "- send email: gws gmail +send --to 'alice@example.com' --subject 'Subject' --body 'Body'", + "For send mail / email / test mail requests, use gws gmail +send and do not substitute inbox or search commands.", + "For requests like 'get 5 emails', prefer gws gmail +triage --max 5 --format table.", + "Never use gws gmail +read, gws gmail list, or gws gmail +list on this node; those command forms are invalid here.", + "When the user asks to keep watch on job-application emails or notify them about inbox changes, schedule a recurring cron task that checks Gmail and sends a notification only when the requested condition is met.", + "If the user gives only a recipient for a test mail, send a concise default test message instead of asking unnecessary follow-up questions.", + "- upcoming events: gws calendar +agenda --days 3 --format table", + "- Drive files: gws drive files list --params '{\"pageSize\":10}' --format table", + "- upload/share a generated file: gws drive files create --upload /path/to/file --json '{\"name\":\"file.pdf\"}' ; gws drive permissions create --params '{\"fileId\":\"\"}' --json '{\"role\":\"reader\",\"type\":\"anyone\"}'", + "If a Google Workspace request fails, continue troubleshooting in chat with gws auth status or gws --help instead of denying access.", + }, "\n") +} + +func detectConversationalOperatorContext(channel string) string { + channel = strings.ToLower(strings.TrimSpace(channel)) + if channel != "telegram" && channel != "whatsapp" && channel != "whatsapp_native" { + return "" + } + + return strings.Join([]string{ + "## Conversational Operator Mode", + "Operate like a persistent coding and ops assistant, not a FAQ bot.", + "When the user asks you to build, fix, recover, install, sync, scan, or monitor something, keep using tools until it is actually done or you hit a concrete external blocker.", + "Do not stop at diagnosis when the next repair or implementation step is available.", + "Use short progress updates while work is ongoing, then report completion with concrete verification.", + "If one user decision is required, ask one concise question in chat and resume execution after the reply.", + "Treat voice-note transcriptions and attachment context as first-class user instructions.", + "When a voice note has already been transcribed into the message, work from the transcript directly instead of trying to re-transcribe the audio.", + "Prefer WhatsApp Native on this node. Do not ask for MATON API, PHONE_NUMBER_ID, or whatsapp-business setup unless the user explicitly requests Meta WhatsApp Business API work.", + "Keep user-facing progress updates and completion messages in English unless the user explicitly requests another reply language.", + "For website/app build requests, publish a preview with host_preview as soon as something is viewable, return the URL proactively, and keep updating that preview while iterating.", + "When the user replies with a screenshot of the preview, inspect the image directly, infer the requested correction from the screenshot plus caption, edit the project, and republish without making the user restate everything as commands.", + "For website template requests, pick a strong free template yourself, replace the prior preview, and send the new URL instead of asking the user to browse template catalogs unless they explicitly want to choose.", + "For images, PDFs, and docs, respond to the attachment content itself in a natural human style, then ask one concise next-step question. Avoid robotic menus like memory-note options unless the user asked for those workflows.", + "When the user asks for export after a translation request, complete the translation first, then write and send the requested file format in the requested language instead of asking them to repeat the format or language.", + "When a Telegram voice note already has a transcript and speech synthesis is available, reply with a voice note when practical and send any URLs, code, or exact commands as a short separate text message.", + "If the free model/router is the blocker on a task that keeps failing, ask one concise question whether to switch the task to paid Gemini or DeepSeek and then continue.", + "Prefer self-repair: inspect logs, check config, retry corrected commands, restart services, and verify the result before handing work back.", + "While chatting naturally, choose the right tool, skill, or MCP path yourself instead of forcing slash commands or registry searches first.", + }, "\n") +} + func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { now := time.Now().Format("2006-01-02 15:04 (Monday)") rt := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) @@ -431,6 +584,14 @@ func (cb *ContextBuilder) buildDynamicContext(channel, chatID string) string { var sb strings.Builder fmt.Fprintf(&sb, "## Current Time\n%s\n\n## Runtime\n%s", now, rt) + if gwsCtx := detectGoogleWorkspaceContext(); gwsCtx != "" { + fmt.Fprintf(&sb, "\n\n%s", gwsCtx) + } + + if opCtx := detectConversationalOperatorContext(channel); opCtx != "" { + fmt.Fprintf(&sb, "\n\n%s", opCtx) + } + if channel != "" && chatID != "" { fmt.Fprintf(&sb, "\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) } diff --git a/pkg/agent/context_capability_test.go b/pkg/agent/context_capability_test.go new file mode 100644 index 000000000..bf724be87 --- /dev/null +++ b/pkg/agent/context_capability_test.go @@ -0,0 +1,33 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildSystemPromptIncludesCapabilityPrimer(t *testing.T) { + tmpDir := t.TempDir() + cb := NewContextBuilder(tmpDir) + prompt := cb.BuildSystemPrompt() + if !strings.Contains(prompt, "# Capability Use") { + t.Fatalf("expected capability primer in prompt, got %q", prompt) + } + if !strings.Contains(prompt, "Skills are local instruction files") { + t.Fatalf("expected skill guidance in prompt, got %q", prompt) + } +} + +func TestDetectMCPContext_NoServersConfigured(t *testing.T) { + home := t.TempDir() + t.Setenv("PICOCLAW_HOME", home) + cfgPath := filepath.Join(home, "config.json") + if err := os.WriteFile(cfgPath, []byte(`{"tools":{"mcp":{"enabled":true,"servers":{}}}}`), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + got := detectMCPContext() + if !strings.Contains(got, "No MCP servers are configured") { + t.Fatalf("unexpected MCP context: %q", got) + } +} diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 97cf0fa05..d735c888c 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -21,6 +21,7 @@ type AgentInstance struct { ID string Name string Model string + ImageModel string Fallbacks []string Workspace string MaxIterations int @@ -37,6 +38,7 @@ type AgentInstance struct { Subagents *config.SubagentsConfig SkillsFilter []string Candidates []providers.FallbackCandidate + ImageCandidates []providers.FallbackCandidate // Router is non-nil when model routing is configured and the light model // was successfully resolved. It scores each incoming message and decides @@ -92,6 +94,9 @@ func NewAgentInstance( if cfg.Tools.IsToolEnabled("append_file") { toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict, allowWritePaths)) } + if cfg.Tools.IsToolEnabled("write_pdf") { + toolsRegistry.Register(tools.NewWritePDFTool(workspace, restrict)) + } sessionsDir := filepath.Join(workspace, "sessions") sessionsManager := session.NewSessionManager(sessionsDir) @@ -141,57 +146,15 @@ func NewAgentInstance( summarizeTokenPercent = 75 } - // Resolve fallback candidates - modelCfg := providers.ModelConfig{ - Primary: model, - Fallbacks: fallbacks, - } - resolveFromModelList := func(raw string) (string, bool) { - ensureProtocol := func(model string) string { - model = strings.TrimSpace(model) - if model == "" { - return "" - } - if strings.Contains(model, "/") { - return model - } - return "openai/" + model - } - - raw = strings.TrimSpace(raw) - if raw == "" { - return "", false - } - - if cfg != nil { - if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { - return ensureProtocol(mc.Model), true - } - - for i := range cfg.ModelList { - fullModel := strings.TrimSpace(cfg.ModelList[i].Model) - if fullModel == "" { - continue - } - if fullModel == raw { - return ensureProtocol(fullModel), true - } - _, modelID := providers.ExtractProtocol(fullModel) - if modelID == raw { - return ensureProtocol(fullModel), true - } - } - } - - return "", false - } - - candidates := providers.ResolveCandidatesWithLookup(modelCfg, defaults.Provider, resolveFromModelList) + candidates := resolveAgentCandidates(cfg, defaults.Provider, model, fallbacks) + imageModel := strings.TrimSpace(defaults.ImageModel) + imageCandidates := resolveAgentCandidates(cfg, defaults.Provider, imageModel, defaults.ImageModelFallbacks) // Model routing setup: pre-resolve light model candidates at creation time // to avoid repeated model_list lookups on every incoming message. var router *routing.Router var lightCandidates []providers.FallbackCandidate + resolveFromModelList := resolveModelLookup(cfg) if rc := defaults.Routing; rc != nil && rc.Enabled && rc.LightModel != "" { lightModelCfg := providers.ModelConfig{Primary: rc.LightModel} resolved := providers.ResolveCandidatesWithLookup(lightModelCfg, defaults.Provider, resolveFromModelList) @@ -211,6 +174,7 @@ func NewAgentInstance( ID: agentID, Name: agentName, Model: model, + ImageModel: imageModel, Fallbacks: fallbacks, Workspace: workspace, MaxIterations: maxIter, @@ -227,11 +191,68 @@ func NewAgentInstance( Subagents: subagents, SkillsFilter: skillsFilter, Candidates: candidates, + ImageCandidates: imageCandidates, Router: router, LightCandidates: lightCandidates, } } +func resolveAgentCandidates( + cfg *config.Config, + defaultProvider string, + primary string, + fallbacks []string, +) []providers.FallbackCandidate { + if strings.TrimSpace(primary) == "" { + return nil + } + modelCfg := providers.ModelConfig{ + Primary: primary, + Fallbacks: fallbacks, + } + return providers.ResolveCandidatesWithLookup(modelCfg, defaultProvider, resolveModelLookup(cfg)) +} + +func resolveModelLookup(cfg *config.Config) func(raw string) (string, bool) { + return func(raw string) (string, bool) { + ensureProtocol := func(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if strings.Contains(model, "/") { + return model + } + return "openai/" + model + } + + raw = strings.TrimSpace(raw) + if raw == "" || cfg == nil { + return "", false + } + + if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { + return ensureProtocol(mc.Model), true + } + + for i := range cfg.ModelList { + fullModel := strings.TrimSpace(cfg.ModelList[i].Model) + if fullModel == "" { + continue + } + if fullModel == raw { + return ensureProtocol(fullModel), true + } + protocol, modelID := providers.ExtractProtocol(fullModel) + if fullModel == raw || modelID == raw || protocol+"/"+modelID == raw { + return ensureProtocol(fullModel), true + } + } + + return "", false + } +} + // resolveAgentWorkspace determines the workspace directory for an agent. func resolveAgentWorkspace(agentCfg *config.AgentConfig, defaults *config.AgentDefaults) string { if agentCfg != nil && strings.TrimSpace(agentCfg.Workspace) != "" { diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 3d13071c0..3b526cd82 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -11,6 +11,8 @@ import ( "encoding/json" "errors" "fmt" + "os" + "os/exec" "path/filepath" "regexp" "strings" @@ -37,17 +39,19 @@ import ( ) type AgentLoop struct { - bus *bus.MessageBus - cfg *config.Config - registry *AgentRegistry - state *state.Manager - running atomic.Bool - summarizing sync.Map - fallback *providers.FallbackChain - channelManager *channels.Manager - mediaStore media.MediaStore - transcriber voice.Transcriber - cmdRegistry *commands.Registry + bus *bus.MessageBus + cfg *config.Config + registry *AgentRegistry + state *state.Manager + running atomic.Bool + summarizing sync.Map + fallback *providers.FallbackChain + channelManager *channels.Manager + mediaStore media.MediaStore + transcriber voice.Transcriber + synthesizer voice.Synthesizer + cmdRegistry *commands.Registry + perAgentToolFactories []func(agentID string, agent *AgentInstance) tools.Tool } // processOptions configures how a message is processed @@ -71,6 +75,8 @@ const ( metadataKeyTeamID = "team_id" metadataKeyParentPeerKind = "parent_peer_kind" metadataKeyParentPeerID = "parent_peer_id" + metadataKeyUserProfileKey = "user_profile_key" + metadataKeyAdminEscalated = "admin_escalated" ) func NewAgentLoop( @@ -107,6 +113,19 @@ func NewAgentLoop( return al } +func normalizeOutboundChannel(channel string, cfg *config.Config) string { + channel = strings.ToLower(strings.TrimSpace(channel)) + switch channel { + case "wa", "whatsapp": + if cfg != nil && cfg.Channels.WhatsApp.UseNative { + return "whatsapp_native" + } + return "whatsapp" + default: + return channel + } +} + // registerSharedTools registers tools that are shared across all agents (web, message, spawn). func registerSharedTools( cfg *config.Config, @@ -119,118 +138,136 @@ func registerSharedTools( if !ok { continue } + registerSharedToolsForAgent(cfg, msgBus, registry, provider, agentID, agent) + } +} - // Web tools - if cfg.Tools.IsToolEnabled("web") { - searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ - BraveAPIKey: cfg.Tools.Web.Brave.APIKey, - BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, - BraveEnabled: cfg.Tools.Web.Brave.Enabled, - TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, - TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, - TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, - TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, - DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, - DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, - PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, - PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, - PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, - SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, - SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, - SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, - GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey, - GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, - GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, - GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, - GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, - Proxy: cfg.Tools.Web.Proxy, - }) - if err != nil { - logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) - } else if searchTool != nil { - agent.Tools.Register(searchTool) +func registerSharedToolsForAgent( + cfg *config.Config, + msgBus *bus.MessageBus, + registry *AgentRegistry, + provider providers.LLMProvider, + agentID string, + agent *AgentInstance, +) { + if agent == nil { + return + } + + // Web tools + if cfg.Tools.IsToolEnabled("web") { + searchTool, err := tools.NewWebSearchTool(tools.WebSearchToolOptions{ + BraveAPIKey: cfg.Tools.Web.Brave.APIKey, + BraveMaxResults: cfg.Tools.Web.Brave.MaxResults, + BraveEnabled: cfg.Tools.Web.Brave.Enabled, + TavilyAPIKey: cfg.Tools.Web.Tavily.APIKey, + TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, + TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, + TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, + DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, + SearXNGBaseURL: cfg.Tools.Web.SearXNG.BaseURL, + SearXNGMaxResults: cfg.Tools.Web.SearXNG.MaxResults, + SearXNGEnabled: cfg.Tools.Web.SearXNG.Enabled, + GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey, + GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL, + GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine, + GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults, + GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled, + Proxy: cfg.Tools.Web.Proxy, + }) + if err != nil { + logger.ErrorCF("agent", "Failed to create web search tool", map[string]any{"error": err.Error()}) + } else if searchTool != nil { + agent.Tools.Register(searchTool) + } + } + if cfg.Tools.IsToolEnabled("web_fetch") { + fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes) + if err != nil { + logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) + } else { + agent.Tools.Register(fetchTool) + } + } + + // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms + if cfg.Tools.IsToolEnabled("i2c") { + agent.Tools.Register(tools.NewI2CTool()) + } + if cfg.Tools.IsToolEnabled("spi") { + agent.Tools.Register(tools.NewSPITool()) + } + + // Message tool + if cfg.Tools.IsToolEnabled("message") { + messageTool := tools.NewMessageTool() + messageTool.SetSendCallback(func(channel, chatID, content string) error { + channel = normalizeOutboundChannel(channel, cfg) + if strings.TrimSpace(channel) == "" { + return fmt.Errorf("message target channel is required") } - } - if cfg.Tools.IsToolEnabled("web_fetch") { - fetchTool, err := tools.NewWebFetchToolWithProxy(50000, cfg.Tools.Web.Proxy, cfg.Tools.Web.FetchLimitBytes) - if err != nil { - logger.ErrorCF("agent", "Failed to create web fetch tool", map[string]any{"error": err.Error()}) - } else { - agent.Tools.Register(fetchTool) - } - } - - // Hardware tools (I2C, SPI) - Linux only, returns error on other platforms - if cfg.Tools.IsToolEnabled("i2c") { - agent.Tools.Register(tools.NewI2CTool()) - } - if cfg.Tools.IsToolEnabled("spi") { - agent.Tools.Register(tools.NewSPITool()) - } - - // Message tool - if cfg.Tools.IsToolEnabled("message") { - messageTool := tools.NewMessageTool() - messageTool.SetSendCallback(func(channel, chatID, content string) error { - pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) - defer pubCancel() - return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: content, - }) + pubCtx, pubCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer pubCancel() + return msgBus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: content, }) - agent.Tools.Register(messageTool) - } + }) + agent.Tools.Register(messageTool) + } - // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) - if cfg.Tools.IsToolEnabled("send_file") { - sendFileTool := tools.NewSendFileTool( - agent.Workspace, - cfg.Agents.Defaults.RestrictToWorkspace, - cfg.Agents.Defaults.GetMaxMediaSize(), - nil, + // Send file tool (outbound media via MediaStore — store injected later by SetMediaStore) + if cfg.Tools.IsToolEnabled("send_file") { + sendFileTool := tools.NewSendFileTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + ) + agent.Tools.Register(sendFileTool) + } + + // Skill discovery and installation tools + skillsEnabled := cfg.Tools.IsToolEnabled("skills") + findSkillsEnabled := cfg.Tools.IsToolEnabled("find_skills") + installSkillsEnabled := cfg.Tools.IsToolEnabled("install_skill") + if skillsEnabled && (findSkillsEnabled || installSkillsEnabled) { + registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ + MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, + ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + }) + + if findSkillsEnabled { + searchCache := skills.NewSearchCache( + cfg.Tools.Skills.SearchCache.MaxSize, + time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, ) - agent.Tools.Register(sendFileTool) + agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) } - // Skill discovery and installation tools - skills_enabled := cfg.Tools.IsToolEnabled("skills") - find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") - install_skills_enable := cfg.Tools.IsToolEnabled("install_skill") - if skills_enabled && (find_skills_enable || install_skills_enable) { - registryMgr := skills.NewRegistryManagerFromConfig(skills.RegistryConfig{ - MaxConcurrentSearches: cfg.Tools.Skills.MaxConcurrentSearches, - ClawHub: skills.ClawHubConfig(cfg.Tools.Skills.Registries.ClawHub), + if installSkillsEnabled { + agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) + } + } + + // Spawn tool with allowlist checker + if cfg.Tools.IsToolEnabled("spawn") { + if cfg.Tools.IsToolEnabled("subagent") { + subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) + subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + spawnTool := tools.NewSpawnTool(subagentManager) + currentAgentID := agentID + spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { + return registry.CanSpawnSubagent(currentAgentID, targetAgentID) }) - - if find_skills_enable { - searchCache := skills.NewSearchCache( - cfg.Tools.Skills.SearchCache.MaxSize, - time.Duration(cfg.Tools.Skills.SearchCache.TTLSeconds)*time.Second, - ) - agent.Tools.Register(tools.NewFindSkillsTool(registryMgr, searchCache)) - } - - if install_skills_enable { - agent.Tools.Register(tools.NewInstallSkillTool(registryMgr, agent.Workspace)) - } - } - - // Spawn tool with allowlist checker - if cfg.Tools.IsToolEnabled("spawn") { - if cfg.Tools.IsToolEnabled("subagent") { - subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) - subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) - spawnTool := tools.NewSpawnTool(subagentManager) - currentAgentID := agentID - spawnTool.SetAllowlistChecker(func(targetAgentID string) bool { - return registry.CanSpawnSubagent(currentAgentID, targetAgentID) - }) - agent.Tools.Register(spawnTool) - } else { - logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil) - } + agent.Tools.Register(spawnTool) + } else { + logger.WarnCF("agent", "spawn tool requires subagent to be enabled", nil) } } } @@ -388,8 +425,39 @@ func (al *AgentLoop) RegisterTool(tool tools.Tool) { } } +func (al *AgentLoop) RegisterPerAgentTool(factory func(agentID string, agent *AgentInstance) tools.Tool) { + if factory == nil { + return + } + al.perAgentToolFactories = append(al.perAgentToolFactories, factory) + for _, agentID := range al.registry.ListAgentIDs() { + if agent, ok := al.registry.GetAgent(agentID); ok { + al.applyPerAgentToolFactory(agentID, agent, factory) + } + } +} + +func (al *AgentLoop) applyPerAgentToolFactory(agentID string, agent *AgentInstance, factory func(agentID string, agent *AgentInstance) tools.Tool) { + if agent == nil || factory == nil { + return + } + if tool := factory(agentID, agent); tool != nil { + agent.Tools.Register(tool) + } +} + +func (al *AgentLoop) applyPerAgentTools(agent *AgentInstance) { + if agent == nil { + return + } + for _, factory := range al.perAgentToolFactories { + al.applyPerAgentToolFactory(agent.ID, agent, factory) + } +} + func (al *AgentLoop) SetChannelManager(cm *channels.Manager) { al.channelManager = cm + al.restoreRecentPreviews() } // SetMediaStore injects a MediaStore for media lifecycle management. @@ -409,7 +477,15 @@ func (al *AgentLoop) SetTranscriber(t voice.Transcriber) { al.transcriber = t } +func (al *AgentLoop) SetSynthesizer(s voice.Synthesizer) { + al.synthesizer = s +} + var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`) +var voiceURLRe = regexp.MustCompile(`https?://[^\s)]+`) +var voiceCodeBlockRe = regexp.MustCompile("(?s)```.*?```") +var voiceInlineCodeRe = regexp.MustCompile("`[^`]+`") +var voiceWhitespaceRe = regexp.MustCompile(`\s+`) // transcribeAudioInMessage resolves audio media refs, transcribes them, and // replaces audio annotations in msg.Content with the transcribed text. @@ -418,48 +494,71 @@ func (al *AgentLoop) transcribeAudioInMessage(ctx context.Context, msg bus.Inbou return msg } - // Transcribe each audio media ref in order. var transcriptions []string + remainingMedia := make([]string, 0, len(msg.Media)) for _, ref := range msg.Media { path, meta, err := al.mediaStore.ResolveWithMeta(ref) if err != nil { logger.WarnCF("voice", "Failed to resolve media ref", map[string]any{"ref": ref, "error": err}) + remainingMedia = append(remainingMedia, ref) continue } if !utils.IsAudioFile(meta.Filename, meta.ContentType) { + remainingMedia = append(remainingMedia, ref) continue } result, err := al.transcriber.Transcribe(ctx, path) if err != nil { logger.WarnCF("voice", "Transcription failed", map[string]any{"ref": ref, "error": err}) - transcriptions = append(transcriptions, "") + remainingMedia = append(remainingMedia, ref) continue } - transcriptions = append(transcriptions, result.Text) + if text := strings.TrimSpace(result.Text); text != "" { + transcriptions = append(transcriptions, text) + continue + } + remainingMedia = append(remainingMedia, ref) } if len(transcriptions) == 0 { return msg } - // Replace audio annotations sequentially with transcriptions. + msg.Media = remainingMedia + msg.Content = mergeVoiceTranscriptions(msg.Content, transcriptions) + return msg +} + +func mergeVoiceTranscriptions(content string, transcriptions []string) string { + if len(transcriptions) == 0 { + return content + } + idx := 0 - newContent := audioAnnotationRe.ReplaceAllStringFunc(msg.Content, func(match string) string { + newContent := audioAnnotationRe.ReplaceAllStringFunc(content, func(match string) string { if idx >= len(transcriptions) { return match } - text := transcriptions[idx] + text := strings.TrimSpace(transcriptions[idx]) idx++ - return "[voice: " + text + "]" + if text == "" { + return match + } + return "Voice note transcript: " + text }) - // Append any remaining transcriptions not matched by an annotation. for ; idx < len(transcriptions); idx++ { - newContent += "\n[voice: " + transcriptions[idx] + "]" + text := strings.TrimSpace(transcriptions[idx]) + if text == "" { + continue + } + if strings.TrimSpace(newContent) != "" { + newContent += "\n" + } + newContent += "Voice note transcript: " + text } - msg.Content = newContent - return msg + return strings.TrimSpace(newContent) } // inferMediaType determines the media type ("image", "audio", "video", "file") @@ -581,6 +680,22 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } route, agent, routeErr := al.resolveMessageRoute(msg) + if routeErr == nil { + agent, routeErr = al.resolveEffectiveAgent(msg, route, agent) + } + + // Commands are checked before requiring a successful route. + // Global commands (/help, /show, /switch) work even when routing fails; + // context-dependent commands check their own Runtime fields and report + // "unavailable" when the required capability is nil. + commandSessionKey := msg.SessionKey + if routeErr == nil { + commandSessionKey = resolveScopeKey(route, msg.SessionKey, isAdminEscalation(msg)) + } + if response, handled := al.handleCommand(ctx, msg, agent, commandSessionKey); handled { + return response, nil + } + if routeErr != nil { return "", routeErr } @@ -593,7 +708,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } // Resolve session key from route, while preserving explicit agent-scoped keys. - scopeKey := resolveScopeKey(route, msg.SessionKey) + scopeKey := resolveScopeKey(route, msg.SessionKey, isAdminEscalation(msg)) sessionKey := scopeKey logger.InfoCF("agent", "Routed message", @@ -606,7 +721,7 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "route_channel": route.Channel, }) - opts := processOptions{ + response, err := al.runAgentLoop(ctx, agent, processOptions{ SessionKey: sessionKey, Channel: msg.Channel, ChatID: msg.ChatID, @@ -615,15 +730,21 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) DefaultResponse: defaultResponse, EnableSummary: true, SendResponse: false, + }) + if err != nil { + return "", err } - - // context-dependent commands check their own Runtime fields and report - // "unavailable" when the required capability is nil. - if response, handled := al.handleCommand(ctx, msg, agent, &opts); handled { - return response, nil + if tool, ok := agent.Tools.Get("message"); ok { + if mt, ok := tool.(interface{ HasSentToCurrentRound() bool }); ok && mt.HasSentToCurrentRound() { + logger.InfoCF("agent", "Suppressing duplicate final response after same-chat message tool send", map[string]any{ + "agent_id": agent.ID, + "channel": msg.Channel, + "chat_id": msg.ChatID, + }) + return "", nil + } } - - return al.runAgentLoop(ctx, agent, opts) + return response, nil } func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.ResolvedRoute, *AgentInstance, error) { @@ -647,13 +768,80 @@ func (al *AgentLoop) resolveMessageRoute(msg bus.InboundMessage) (routing.Resolv return route, agent, nil } -func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string) string { +func resolveScopeKey(route routing.ResolvedRoute, msgSessionKey string, adminEscalated bool) string { if msgSessionKey != "" && strings.HasPrefix(msgSessionKey, sessionKeyAgentPrefix) { return msgSessionKey } + if adminEscalated && route.MainSessionKey != "" { + return route.MainSessionKey + } return route.SessionKey } +func (al *AgentLoop) resolveEffectiveAgent( + msg bus.InboundMessage, + route routing.ResolvedRoute, + baseAgent *AgentInstance, +) (*AgentInstance, error) { + if baseAgent == nil { + return nil, fmt.Errorf("no base agent available") + } + if isAdminEscalation(msg) { + return baseAgent, nil + } + + profileKey := inboundMetadata(msg, metadataKeyUserProfileKey) + if profileKey == "" { + profileKey = deriveUserProfileKey(msg) + } + if profileKey == "" { + return baseAgent, nil + } + + baseAgentID := baseAgent.ID + if baseAgentID == "" { + baseAgentID = route.AgentID + } + profileAgent, created, err := al.registry.GetOrCreateProfileAgent(baseAgentID, profileKey) + if err != nil { + return nil, err + } + if created { + registerSharedToolsForAgent(al.cfg, al.bus, al.registry, baseAgent.Provider, baseAgentID, profileAgent) + if al.mediaStore != nil { + if tool, ok := profileAgent.Tools.Get("send_file"); ok { + if sendFileTool, ok := tool.(*tools.SendFileTool); ok { + sendFileTool.SetMediaStore(al.mediaStore) + } + } + } + al.applyPerAgentTools(profileAgent) + logger.InfoCF("agent", "Created isolated profile agent", map[string]any{ + "agent_id": baseAgentID, + "profile_key": profileKey, + "profile_folder": profileAgent.Workspace, + }) + } + return profileAgent, nil +} + +func isAdminEscalation(msg bus.InboundMessage) bool { + return strings.EqualFold(strings.TrimSpace(inboundMetadata(msg, metadataKeyAdminEscalated)), "true") +} + +func deriveUserProfileKey(msg bus.InboundMessage) string { + if msg.Sender.CanonicalID != "" { + return msg.Sender.CanonicalID + } + if msg.Sender.Platform != "" && msg.Sender.PlatformID != "" { + return fmt.Sprintf("%s:%s", msg.Sender.Platform, msg.Sender.PlatformID) + } + if msg.Channel != "" && inboundMetadata(msg, "user_id") != "" { + return fmt.Sprintf("%s:%s", msg.Channel, inboundMetadata(msg, "user_id")) + } + return "" +} + func (al *AgentLoop) processSystemMessage( ctx context.Context, msg bus.InboundMessage, @@ -746,10 +934,19 @@ func (al *AgentLoop) runAgentLoop( history = agent.Sessions.GetHistory(opts.SessionKey) summary = agent.Sessions.GetSummary(opts.SessionKey) } + attachmentCtx := buildAttachmentContext(opts.Media, al.mediaStore) + userMessageForModel := opts.UserMessage + if attachmentCtx != "" { + if strings.TrimSpace(userMessageForModel) != "" { + userMessageForModel = strings.TrimSpace(userMessageForModel) + "\n\n" + attachmentCtx + } else { + userMessageForModel = attachmentCtx + } + } messages := agent.ContextBuilder.BuildMessages( history, summary, - opts.UserMessage, + userMessageForModel, opts.Media, opts.Channel, opts.ChatID, @@ -760,7 +957,7 @@ func (al *AgentLoop) runAgentLoop( messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) // 2. Save user message to session - agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage) + agent.Sessions.AddMessage(opts.SessionKey, "user", userMessageForModel) // 3. Run LLM iteration loop finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts) @@ -776,6 +973,11 @@ func (al *AgentLoop) runAgentLoop( finalContent = opts.DefaultResponse } + // Guard against raw tool-call payload leakage in Telegram responses. + if opts.Channel == "telegram" { + finalContent = sanitizeLeakedToolPayload(finalContent) + } + // 5. Save final assistant message to session agent.Sessions.AddMessage(opts.SessionKey, "assistant", finalContent) agent.Sessions.Save(opts.SessionKey) @@ -787,11 +989,20 @@ func (al *AgentLoop) runAgentLoop( // 7. Optional: send response via bus if opts.SendResponse { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: finalContent, - }) + voiceSent, supplemental := al.maybeSendVoiceReply(ctx, opts, finalContent) + if !voiceSent { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: finalContent, + }) + } else if strings.TrimSpace(supplemental) != "" { + al.bus.PublishOutbound(ctx, bus.OutboundMessage{ + Channel: opts.Channel, + ChatID: opts.ChatID, + Content: supplemental, + }) + } } // 8. Log response @@ -877,16 +1088,18 @@ func (al *AgentLoop) runLLMIteration( // selectCandidates evaluates routing once and the decision is sticky for // all tool-follow-up iterations within the same turn so that a multi-step // tool chain doesn't switch models mid-way through. - activeCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages) + activeCandidates, activeModel := al.selectCandidates(agent, opts.UserMessage, messages, opts.Media) - for iteration < agent.MaxIterations { + maxIterations := effectiveConversationIterations(opts.Channel, agent.MaxIterations) + + for iteration < maxIterations { iteration++ logger.DebugCF("agent", "LLM iteration", map[string]any{ "agent_id": agent.ID, "iteration": iteration, - "max": agent.MaxIterations, + "max": maxIterations, }) // Build tool definitions @@ -939,7 +1152,7 @@ func (al *AgentLoop) runLLMIteration( ctx, activeCandidates, func(ctx context.Context, provider, model string) (*providers.LLMResponse, error) { - return agent.Provider.Chat(ctx, messages, providerToolDefs, model, llmOpts) + return al.callCandidateChat(ctx, agent, messages, providerToolDefs, provider, model, llmOpts) }, ) if fbErr != nil { @@ -955,7 +1168,11 @@ func (al *AgentLoop) runLLMIteration( } return fbResult.Response, nil } - return agent.Provider.Chat(ctx, messages, providerToolDefs, activeModel, llmOpts) + primaryProvider := al.cfg.Agents.Defaults.Provider + if len(activeCandidates) > 0 && strings.TrimSpace(activeCandidates[0].Provider) != "" { + primaryProvider = activeCandidates[0].Provider + } + return al.callCandidateChat(ctx, agent, messages, providerToolDefs, primaryProvider, activeModel, llmOpts) } // Retry loop for context/token errors @@ -1007,11 +1224,10 @@ func (al *AgentLoop) runLLMIteration( }, ) - if retry == 0 && !constants.IsInternalChannel(opts.Channel) { - al.bus.PublishOutbound(ctx, bus.OutboundMessage{ - Channel: opts.Channel, - ChatID: opts.ChatID, - Content: "Context window exceeded. Compressing history and retrying...", + if retry == 0 { + logger.InfoCF("agent", "Suppressing user-facing compression notice", map[string]any{ + "channel": opts.Channel, + "chat_id": opts.ChatID, }) } @@ -1054,6 +1270,16 @@ func (al *AgentLoop) runLLMIteration( "target_channel": al.targetReasoningChannelID(opts.Channel), "channel": opts.Channel, }) + // Some providers occasionally emit wrapped tool-call payloads in text, + // e.g. CALL>[{"name":"read_file","arguments":{...}}]ALL>. + // Convert those payloads into executable tool calls instead of replying with raw wrappers. + if len(response.ToolCalls) == 0 { + if wrapped := extractWrappedToolCalls(response.Content); len(wrapped) > 0 { + response.ToolCalls = wrapped + response.Content = "" + } + } + // Check if no tool calls - then check reasoning content if any if len(response.ToolCalls) == 0 { finalContent = response.Content @@ -1071,7 +1297,9 @@ func (al *AgentLoop) runLLMIteration( normalizedToolCalls := make([]providers.ToolCall, 0, len(response.ToolCalls)) for _, tc := range response.ToolCalls { - normalizedToolCalls = append(normalizedToolCalls, providers.NormalizeToolCall(tc)) + norm := providers.NormalizeToolCall(tc) + norm.Arguments = rewriteToolArguments(norm.Name, norm.Arguments, agent, opts.Media, al.mediaStore) + normalizedToolCalls = append(normalizedToolCalls, norm) } // Log tool calls @@ -1170,6 +1398,14 @@ func (al *AgentLoop) runLLMIteration( if content == "" { return } + if opts.NoHistory { + logger.InfoCF("agent", "Async tool completed during no-history run; suppressing publish", map[string]any{ + "tool": tc.Name, + "content_len": len(content), + "channel": opts.Channel, + }) + return + } logger.InfoCF("agent", "Async tool completed, publishing result", map[string]any{ @@ -1271,7 +1507,21 @@ func (al *AgentLoop) selectCandidates( agent *AgentInstance, userMsg string, history []providers.Message, + currentMedia []string, ) (candidates []providers.FallbackCandidate, model string) { + if len(agent.ImageCandidates) > 0 && turnContainsImageMedia(currentMedia, al.mediaStore) { + imageModel := agent.ImageModel + if imageModel == "" { + imageModel = agent.ImageCandidates[0].Model + } + logger.InfoCF("agent", "Model routing: image model selected", + map[string]any{ + "agent_id": agent.ID, + "image_model": imageModel, + }) + return agent.ImageCandidates, imageModel + } + if agent.Router == nil || len(agent.LightCandidates) == 0 { return agent.Candidates, agent.Model } @@ -1297,6 +1547,223 @@ func (al *AgentLoop) selectCandidates( return agent.LightCandidates, agent.Router.LightModel() } +func turnContainsImageMedia(refs []string, store media.MediaStore) bool { + for _, ref := range refs { + trimmed := strings.TrimSpace(ref) + if trimmed == "" { + continue + } + lower := strings.ToLower(trimmed) + if strings.HasPrefix(lower, "data:image/") { + return true + } + if !strings.HasPrefix(lower, "media://") || store == nil { + continue + } + _, meta, err := store.ResolveWithMeta(trimmed) + if err != nil { + continue + } + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(meta.ContentType)), "image/") { + return true + } + switch strings.ToLower(filepath.Ext(strings.TrimSpace(meta.Filename))) { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".heic", ".heif": + return true + } + } + return false +} + +func (al *AgentLoop) callCandidateChat( + ctx context.Context, + agent *AgentInstance, + messages []providers.Message, + toolDefs []providers.ToolDefinition, + providerName string, + model string, + options map[string]any, +) (*providers.LLMResponse, error) { + provider, resolvedModel, created, err := al.providerForCandidate(providerName, model) + if err != nil { + return nil, err + } + if provider == nil { + if agent == nil || agent.Provider == nil { + return nil, fmt.Errorf("provider not available for %s/%s", providerName, model) + } + provider = agent.Provider + if strings.TrimSpace(resolvedModel) == "" { + resolvedModel = model + } + } + if created { + if stateful, ok := provider.(providers.StatefulProvider); ok { + defer stateful.Close() + } + } + return provider.Chat(ctx, messages, toolDefs, resolvedModel, options) +} + +func (al *AgentLoop) providerForCandidate(providerName, model string) (providers.LLMProvider, string, bool, error) { + modelCfg, err := al.modelConfigForCandidate(providerName, model) + if err != nil { + return nil, "", false, err + } + provider, resolvedModel, err := providers.CreateProviderFromConfig(modelCfg) + if err != nil { + return nil, "", false, err + } + return provider, resolvedModel, true, nil +} + +func (al *AgentLoop) modelConfigForCandidate(providerName, model string) (*config.ModelConfig, error) { + normalizedProvider := providers.NormalizeProvider(providerName) + trimmedModel := strings.TrimSpace(model) + if trimmedModel == "" { + return nil, fmt.Errorf("candidate model is empty for provider %q", providerName) + } + + for i := range al.cfg.ModelList { + entry := al.cfg.ModelList[i] + protocol, modelID := providers.ExtractProtocol(strings.TrimSpace(entry.Model)) + if providers.NormalizeProvider(protocol) == normalizedProvider && strings.TrimSpace(modelID) == trimmedModel { + copy := entry + al.applyProviderDefaults(©, normalizedProvider) + return ©, nil + } + } + + modelCfg := &config.ModelConfig{ + ModelName: normalizedProvider + "-" + sanitizeModelName(trimmedModel), + Model: normalizedProvider + "/" + trimmedModel, + } + + switch normalizedProvider { + case "openrouter": + modelCfg.APIKey = al.cfg.Providers.OpenRouter.APIKey + modelCfg.APIBase = al.cfg.Providers.OpenRouter.APIBase + modelCfg.Proxy = al.cfg.Providers.OpenRouter.Proxy + case "gemini": + modelCfg.APIKey = al.cfg.Providers.Gemini.APIKey + modelCfg.APIBase = al.cfg.Providers.Gemini.APIBase + modelCfg.Proxy = al.cfg.Providers.Gemini.Proxy + case "deepseek": + modelCfg.APIKey = al.cfg.Providers.DeepSeek.APIKey + modelCfg.APIBase = al.cfg.Providers.DeepSeek.APIBase + modelCfg.Proxy = al.cfg.Providers.DeepSeek.Proxy + case "groq": + modelCfg.APIKey = al.cfg.Providers.Groq.APIKey + modelCfg.APIBase = al.cfg.Providers.Groq.APIBase + modelCfg.Proxy = al.cfg.Providers.Groq.Proxy + case "openai": + modelCfg.APIKey = al.cfg.Providers.OpenAI.APIKey + modelCfg.APIBase = al.cfg.Providers.OpenAI.APIBase + modelCfg.Proxy = al.cfg.Providers.OpenAI.Proxy + modelCfg.AuthMethod = al.cfg.Providers.OpenAI.AuthMethod + case "anthropic": + modelCfg.APIKey = al.cfg.Providers.Anthropic.APIKey + modelCfg.APIBase = al.cfg.Providers.Anthropic.APIBase + modelCfg.Proxy = al.cfg.Providers.Anthropic.Proxy + modelCfg.AuthMethod = al.cfg.Providers.Anthropic.AuthMethod + default: + return nil, fmt.Errorf("no model_list entry for candidate %s/%s", normalizedProvider, trimmedModel) + } + + if strings.TrimSpace(modelCfg.APIKey) == "" && strings.TrimSpace(modelCfg.APIBase) == "" && strings.TrimSpace(modelCfg.AuthMethod) == "" { + return nil, fmt.Errorf("credentials not configured for candidate %s/%s", normalizedProvider, trimmedModel) + } + return modelCfg, nil +} + +func (al *AgentLoop) applyProviderDefaults(modelCfg *config.ModelConfig, providerName string) { + if modelCfg == nil { + return + } + switch providers.NormalizeProvider(providerName) { + case "openrouter": + if strings.TrimSpace(modelCfg.APIKey) == "" { + modelCfg.APIKey = al.cfg.Providers.OpenRouter.APIKey + } + if strings.TrimSpace(modelCfg.APIBase) == "" { + modelCfg.APIBase = al.cfg.Providers.OpenRouter.APIBase + } + if strings.TrimSpace(modelCfg.Proxy) == "" { + modelCfg.Proxy = al.cfg.Providers.OpenRouter.Proxy + } + case "gemini": + if strings.TrimSpace(modelCfg.APIKey) == "" { + modelCfg.APIKey = al.cfg.Providers.Gemini.APIKey + } + if strings.TrimSpace(modelCfg.APIBase) == "" { + modelCfg.APIBase = al.cfg.Providers.Gemini.APIBase + } + if strings.TrimSpace(modelCfg.Proxy) == "" { + modelCfg.Proxy = al.cfg.Providers.Gemini.Proxy + } + case "deepseek": + if strings.TrimSpace(modelCfg.APIKey) == "" { + modelCfg.APIKey = al.cfg.Providers.DeepSeek.APIKey + } + if strings.TrimSpace(modelCfg.APIBase) == "" { + modelCfg.APIBase = al.cfg.Providers.DeepSeek.APIBase + } + if strings.TrimSpace(modelCfg.Proxy) == "" { + modelCfg.Proxy = al.cfg.Providers.DeepSeek.Proxy + } + case "groq": + if strings.TrimSpace(modelCfg.APIKey) == "" { + modelCfg.APIKey = al.cfg.Providers.Groq.APIKey + } + if strings.TrimSpace(modelCfg.APIBase) == "" { + modelCfg.APIBase = al.cfg.Providers.Groq.APIBase + } + if strings.TrimSpace(modelCfg.Proxy) == "" { + modelCfg.Proxy = al.cfg.Providers.Groq.Proxy + } + case "openai": + if strings.TrimSpace(modelCfg.APIKey) == "" { + modelCfg.APIKey = al.cfg.Providers.OpenAI.APIKey + } + if strings.TrimSpace(modelCfg.APIBase) == "" { + modelCfg.APIBase = al.cfg.Providers.OpenAI.APIBase + } + if strings.TrimSpace(modelCfg.Proxy) == "" { + modelCfg.Proxy = al.cfg.Providers.OpenAI.Proxy + } + if strings.TrimSpace(modelCfg.AuthMethod) == "" { + modelCfg.AuthMethod = al.cfg.Providers.OpenAI.AuthMethod + } + case "anthropic": + if strings.TrimSpace(modelCfg.APIKey) == "" { + modelCfg.APIKey = al.cfg.Providers.Anthropic.APIKey + } + if strings.TrimSpace(modelCfg.APIBase) == "" { + modelCfg.APIBase = al.cfg.Providers.Anthropic.APIBase + } + if strings.TrimSpace(modelCfg.Proxy) == "" { + modelCfg.Proxy = al.cfg.Providers.Anthropic.Proxy + } + if strings.TrimSpace(modelCfg.AuthMethod) == "" { + modelCfg.AuthMethod = al.cfg.Providers.Anthropic.AuthMethod + } + } +} + +func sanitizeModelName(model string) string { + model = strings.ToLower(strings.TrimSpace(model)) + if model == "" { + return "model" + } + replacer := strings.NewReplacer("/", "-", ":", "-", ".", "-", " ", "-", "_", "-") + model = replacer.Replace(model) + model = strings.Trim(model, "-") + if model == "" { + return "model" + } + return model +} + // maybeSummarize triggers summarization if the session history exceeds thresholds. func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey, channel, chatID string) { newHistory := agent.Sessions.GetHistory(sessionKey) @@ -1491,20 +1958,10 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { return } - const ( - maxSummarizationMessages = 10 - llmMaxRetries = 3 - llmTemperature = 0.3 - fallbackMaxContentLength = 200 - ) - // Multi-Part Summarization var finalSummary string - if len(validMessages) > maxSummarizationMessages { + if len(validMessages) > 10 { mid := len(validMessages) / 2 - - mid = al.findNearestUserMessage(validMessages, mid) - part1 := validMessages[:mid] part2 := validMessages[mid:] @@ -1516,9 +1973,18 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { s1, s2, ) - - resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) - if err == nil && resp.Content != "" { + resp, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: mergePrompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.3, + "prompt_cache_key": agent.ID, + }, + ) + if err == nil { finalSummary = resp.Content } else { finalSummary = s1 + " " + s2 @@ -1538,68 +2004,6 @@ func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string) { } } -// findNearestUserMessage finds the nearest user message to the given index. -// It searches backward first, then forward if no user message is found. -func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int { - originalMid := mid - - for mid > 0 && messages[mid].Role != "user" { - mid-- - } - - if messages[mid].Role == "user" { - return mid - } - - mid = originalMid - for mid < len(messages) && messages[mid].Role != "user" { - mid++ - } - - if mid < len(messages) { - return mid - } - - return originalMid -} - -// retryLLMCall calls the LLM with retry logic. -func (al *AgentLoop) retryLLMCall( - ctx context.Context, - agent *AgentInstance, - prompt string, - maxRetries int, -) (*providers.LLMResponse, error) { - const ( - llmTemperature = 0.3 - ) - - var resp *providers.LLMResponse - var err error - - for attempt := 0; attempt < maxRetries; attempt++ { - resp, err = agent.Provider.Chat( - ctx, - []providers.Message{{Role: "user", Content: prompt}}, - nil, - agent.Model, - map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": llmTemperature, - "prompt_cache_key": agent.ID, - }, - ) - if err == nil && resp != nil && resp.Content != "" { - return resp, nil - } - if attempt < maxRetries-1 { - time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) - } - } - - return resp, err -} - // summarizeBatch summarizes a batch of messages. func (al *AgentLoop) summarizeBatch( ctx context.Context, @@ -1607,13 +2011,6 @@ func (al *AgentLoop) summarizeBatch( batch []providers.Message, existingSummary string, ) (string, error) { - const ( - llmMaxRetries = 3 - llmTemperature = 0.3 - fallbackMinContentLength = 200 - fallbackMaxContentPercent = 10 - ) - var sb strings.Builder sb.WriteString( "Provide a concise summary of this conversation segment, preserving core context and key points.\n", @@ -1629,40 +2026,21 @@ func (al *AgentLoop) summarizeBatch( } prompt := sb.String() - response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries) - if err == nil && response.Content != "" { - return strings.TrimSpace(response.Content), nil + response, err := agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": 1024, + "temperature": 0.3, + "prompt_cache_key": agent.ID, + }, + ) + if err != nil { + return "", err } - - var fallback strings.Builder - fallback.WriteString("Conversation summary: ") - for i, m := range batch { - if i > 0 { - fallback.WriteString(" | ") - } - content := strings.TrimSpace(m.Content) - runes := []rune(content) - if len(runes) == 0 { - fallback.WriteString(fmt.Sprintf("%s: ", m.Role)) - continue - } - - keepLength := len(runes) * fallbackMaxContentPercent / 100 - if keepLength < fallbackMinContentLength { - keepLength = fallbackMinContentLength - } - - if keepLength > len(runes) { - keepLength = len(runes) - } - - content = string(runes[:keepLength]) - if keepLength < len(runes) { - content += "..." - } - fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content)) - } - return fallback.String(), nil + return response.Content, nil } // estimateTokens estimates the number of tokens in a message list. @@ -1681,9 +2059,17 @@ func (al *AgentLoop) handleCommand( ctx context.Context, msg bus.InboundMessage, agent *AgentInstance, - opts *processOptions, + sessionKey string, ) (string, bool) { - if !commands.HasCommandPrefix(msg.Content) { + commandText := msg.Content + if !commands.HasCommandPrefix(commandText) { + if inferred := al.inferImplicitTelegramCommand(msg, commandText, agent); inferred != "" { + commandText = inferred + } else if shouldAutoRunShellInTelegram(msg, commandText) { + commandText = "/run " + strings.TrimSpace(commandText) + } + } + if !commands.HasCommandPrefix(commandText) { return "", false } @@ -1691,7 +2077,7 @@ func (al *AgentLoop) handleCommand( return "", false } - rt := al.buildCommandsRuntime(agent, opts) + rt := al.buildCommandsRuntime(agent, sessionKey) executor := commands.NewExecutor(al.cmdRegistry, rt) var commandReply string @@ -1699,7 +2085,7 @@ func (al *AgentLoop) handleCommand( Channel: msg.Channel, ChatID: msg.ChatID, SenderID: msg.SenderID, - Text: msg.Content, + Text: commandText, Reply: func(text string) error { commandReply = text return nil @@ -1720,7 +2106,7 @@ func (al *AgentLoop) handleCommand( } } -func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOptions) *commands.Runtime { +func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey string) *commands.Runtime { rt := &commands.Runtime{ Config: al.cfg, ListAgentIDs: al.registry.ListAgentIDs, @@ -1736,10 +2122,45 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt return fmt.Errorf("channel manager not initialized") } if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { - return fmt.Errorf("channel '%s' not found or not enabled", value) + return fmt.Errorf("channel %s not found or not enabled", value) } return nil }, + ExecuteShell: func(ctx context.Context, command string) (string, error) { + if !strings.EqualFold(strings.TrimSpace(os.Getenv("PICOCLAW_DASHBOARD_ALLOW_SHELL")), "true") { + return "", fmt.Errorf("shell execution disabled; set PICOCLAW_DASHBOARD_ALLOW_SHELL=true") + } + if strings.TrimSpace(command) == "" { + return "", fmt.Errorf("command is required") + } + cmdCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + cmd := exec.CommandContext(cmdCtx, "bash", "-lc", command) + if agent != nil && strings.TrimSpace(agent.Workspace) != "" { + cmd.Dir = agent.Workspace + } + out, err := cmd.CombinedOutput() + text := formatShellOutputForChat(command, strings.TrimSpace(string(out))) + if len(text) > 6000 { + text = text[:6000] + "\n... (truncated)" + } + return text, err + }, + GetRecentPreviews: func() []commands.PreviewInfo { + return al.recentPreviewInfos(agent) + }, + ClearHistory: func() error { + if strings.TrimSpace(sessionKey) == "" { + return fmt.Errorf("session key is empty") + } + if agent == nil || agent.Sessions == nil { + return fmt.Errorf("sessions not initialized for agent") + } + agent.Sessions.SetHistory(sessionKey, make([]providers.Message, 0)) + agent.Sessions.SetSummary(sessionKey, "") + agent.Sessions.Save(sessionKey) + return nil + }, } if agent != nil { rt.GetModelInfo = func() (string, string) { @@ -1747,27 +2168,904 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt } rt.SwitchModel = func(value string) (string, error) { oldModel := agent.Model - agent.Model = value + candidates := resolveAgentCandidates(al.cfg, al.cfg.Agents.Defaults.Provider, value, agent.Fallbacks) + if len(candidates) == 0 { + return oldModel, fmt.Errorf("model %q not found", value) + } + agent.Model = candidates[0].Model + agent.Candidates = candidates return oldModel, nil } - - rt.ClearHistory = func() error { - if opts == nil { - return fmt.Errorf("process options not available") - } - if agent.Sessions == nil { - return fmt.Errorf("sessions not initialized for agent") - } - - agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0)) - agent.Sessions.SetSummary(opts.SessionKey, "") - agent.Sessions.Save(opts.SessionKey) - return nil - } } return rt } +func (al *AgentLoop) recentPreviewInfos(agent *AgentInstance) []commands.PreviewInfo { + if agent == nil || strings.TrimSpace(agent.Workspace) == "" { + return nil + } + items, err := tools.LoadRecentPreviews(agent.Workspace) + if err != nil || len(items) == 0 { + return nil + } + result := make([]commands.PreviewInfo, 0, len(items)) + for _, item := range items { + refreshed, ok := al.refreshRecentPreview(agent.Workspace, item) + if !ok { + continue + } + result = append(result, commands.PreviewInfo{ + Slug: refreshed.Slug, + LocalURL: refreshed.LocalURL, + TailscaleURL: refreshed.TailscaleURL, + Root: refreshed.Root, + Entry: refreshed.Entry, + }) + } + return result +} + +func (al *AgentLoop) restoreRecentPreviews() { + if al == nil || al.channelManager == nil || al.registry == nil { + return + } + + seen := make(map[string]struct{}) + for _, workspace := range al.recentPreviewWorkspaces() { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + continue + } + if _, ok := seen[workspace]; ok { + continue + } + seen[workspace] = struct{}{} + + items, err := tools.LoadRecentPreviews(workspace) + if err != nil || len(items) == 0 { + continue + } + for _, item := range items { + al.refreshRecentPreview(workspace, item) + } + } +} + +func (al *AgentLoop) recentPreviewWorkspaces() []string { + if al == nil || al.registry == nil { + return nil + } + + var workspaces []string + appendWorkspace := func(workspace string) { + workspace = strings.TrimSpace(workspace) + if workspace == "" { + return + } + workspaces = append(workspaces, workspace) + profilesDir := filepath.Join(workspace, "profiles") + entries, err := os.ReadDir(profilesDir) + if err != nil { + return + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + workspaces = append(workspaces, filepath.Join(profilesDir, entry.Name())) + } + } + + al.registry.mu.RLock() + defer al.registry.mu.RUnlock() + for _, agent := range al.registry.agents { + if agent != nil { + appendWorkspace(agent.Workspace) + } + } + for _, agent := range al.registry.profileAgents { + if agent != nil { + appendWorkspace(agent.Workspace) + } + } + return workspaces +} + +func (al *AgentLoop) refreshRecentPreview(workspace string, item tools.HostedPreview) (tools.HostedPreview, bool) { + root := strings.TrimSpace(item.Root) + workspace = strings.TrimSpace(workspace) + if workspace == "" || root == "" || root == string(filepath.Separator) || al.channelManager == nil { + return tools.HostedPreview{}, false + } + info, err := os.Stat(root) + if err != nil || !info.IsDir() { + return tools.HostedPreview{}, false + } + + slug := strings.TrimSpace(item.Slug) + entry := strings.TrimSpace(item.Entry) + actualSlug, tailscaleURL, localURL, err := al.channelManager.PublishPreview(root, entry, slug) + if err != nil { + return tools.HostedPreview{}, false + } + + refreshed := item + refreshed.Slug = actualSlug + refreshed.Root = root + refreshed.Entry = entry + refreshed.LocalURL = localURL + refreshed.TailscaleURL = tailscaleURL + refreshed.UpdatedAt = time.Now().Format(time.RFC3339) + _ = tools.SaveRecentPreview(workspace, &refreshed) + return refreshed, true +} + +func formatShellOutputForChat(command, output string) string { + trimmed := strings.TrimSpace(output) + if trimmed == "" { + return trimmed + } + lowerCommand := strings.ToLower(command) + switch { + case strings.Contains(lowerCommand, "gws gmail +send"): + return formatGWSSendOutput(command, trimmed) + case strings.Contains(lowerCommand, "gws gmail +triage"): + if formatted := formatGWSTableOutput(trimmed); formatted != "" { + return formatted + } + } + return trimmed +} + +func formatGWSSendOutput(command, output string) string { + var payload map[string]any + if err := json.Unmarshal([]byte(output), &payload); err != nil { + return output + } + lines := []string{"Mail sent."} + if to := strings.TrimSpace(extractCLIFlagValue(command, "--to")); to != "" { + lines = append(lines, "To: "+to) + } + if subject := strings.TrimSpace(extractCLIFlagValue(command, "--subject")); subject != "" { + lines = append(lines, "Subject: "+subject) + } + if id := strings.TrimSpace(fmt.Sprint(payload["id"])); id != "" && id != "" { + lines = append(lines, "ID: "+id) + } + if threadID := strings.TrimSpace(fmt.Sprint(payload["threadId"])); threadID != "" && threadID != "" { + lines = append(lines, "Thread: "+threadID) + } + return strings.Join(lines, "\n") +} + +func extractCLIFlagValue(command, flag string) string { + re := regexp.MustCompile(regexp.QuoteMeta(flag) + `\s+(?:"([^"]+)"|'([^']+)'|(\S+))`) + match := re.FindStringSubmatch(command) + if len(match) == 0 { + return "" + } + for _, candidate := range match[1:] { + if strings.TrimSpace(candidate) != "" { + return candidate + } + } + return "" +} + +func formatGWSTableOutput(output string) string { + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 3 { + return "" + } + header := strings.ToLower(lines[0]) + if !strings.Contains(header, "date") || !strings.Contains(header, "from") || !strings.Contains(header, "subject") { + return "" + } + splitter := regexp.MustCompile(`\s{2,}`) + formatted := []string{"Latest emails:"} + for _, line := range lines[2:] { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := splitter.Split(line, 4) + if len(parts) < 4 { + continue + } + formatted = append(formatted, fmt.Sprintf("• %s\nFrom: %s\nSubject: %s\nID: %s", compactWhitespace(parts[0]), compactWhitespace(parts[1]), compactWhitespace(parts[3]), compactWhitespace(parts[2]))) + } + if len(formatted) == 1 { + return "" + } + return strings.Join(formatted, "\n") +} + +func compactWhitespace(value string) string { + return strings.Join(strings.Fields(strings.TrimSpace(value)), " ") +} + +func shouldSendVoiceReply(opts processOptions) bool { + if !strings.EqualFold(strings.TrimSpace(opts.Channel), "telegram") { + return false + } + return strings.Contains(opts.UserMessage, "Voice note transcript:") +} + +func splitVoiceReplyContent(content string) (string, string) { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return "", "" + } + urls := uniqueStrings(voiceURLRe.FindAllString(trimmed, -1)) + codeBlocks := uniqueStrings(voiceCodeBlockRe.FindAllString(trimmed, -1)) + inlineCodes := uniqueStrings(voiceInlineCodeRe.FindAllString(trimmed, -1)) + + spoken := voiceCodeBlockRe.ReplaceAllString(trimmed, " ") + spoken = voiceInlineCodeRe.ReplaceAllString(spoken, " ") + spoken = voiceURLRe.ReplaceAllString(spoken, " ") + spoken = strings.NewReplacer("**", "", "__", "", "~~", "", "#", "", ">", "").Replace(spoken) + spoken = voiceWhitespaceRe.ReplaceAllString(strings.TrimSpace(spoken), " ") + spoken = trimSpeechText(spoken, 900) + + var supplemental []string + if len(urls) > 0 { + supplemental = append(supplemental, "Links:\n• "+strings.Join(urls, "\n• ")) + } + var details []string + for _, block := range codeBlocks { + block = strings.TrimSpace(strings.TrimPrefix(strings.TrimSuffix(block, "```"), "```")) + if block != "" { + details = append(details, block) + } + } + for _, code := range inlineCodes { + code = strings.Trim(strings.TrimSpace(code), "`") + if code != "" { + details = append(details, code) + } + } + if len(details) > 0 { + supplemental = append(supplemental, "Code/details:\n```\n"+strings.Join(details, "\n\n")+"\n```") + } + return spoken, strings.TrimSpace(strings.Join(supplemental, "\n\n")) +} + +func uniqueStrings(items []string) []string { + seen := make(map[string]struct{}, len(items)) + out := make([]string, 0, len(items)) + for _, item := range items { + item = strings.TrimSpace(item) + if item == "" { + continue + } + if _, ok := seen[item]; ok { + continue + } + seen[item] = struct{}{} + out = append(out, item) + } + return out +} + +func trimSpeechText(text string, limit int) string { + text = strings.TrimSpace(text) + if limit <= 0 || len([]rune(text)) <= limit { + return text + } + runes := []rune(text) + cut := string(runes[:limit]) + if idx := strings.LastIndexAny(cut, ".!?\n"); idx > 120 { + cut = cut[:idx+1] + } + return strings.TrimSpace(cut) +} + +func (al *AgentLoop) maybeSendVoiceReply(ctx context.Context, opts processOptions, finalContent string) (bool, string) { + if !shouldSendVoiceReply(opts) || al.synthesizer == nil || al.mediaStore == nil || al.bus == nil { + return false, "" + } + spoken, supplemental := splitVoiceReplyContent(finalContent) + if strings.TrimSpace(spoken) == "" { + return false, supplemental + } + resp, err := al.synthesizer.Synthesize(ctx, spoken) + if err != nil { + logger.WarnCF("voice", "Voice reply synthesis failed", map[string]any{"channel": opts.Channel, "chat_id": opts.ChatID, "error": err.Error()}) + return false, supplemental + } + scope := channels.BuildMediaScope(opts.Channel, opts.ChatID, fmt.Sprintf("voice-reply-%d", time.Now().UnixNano())) + ref, err := al.mediaStore.Store(resp.AudioFilePath, media.MediaMeta{Filename: resp.Filename, ContentType: resp.ContentType, Source: "tool:voice-reply:" + al.synthesizer.Name()}, scope) + if err != nil { + logger.WarnCF("voice", "Failed to store synthesized voice reply", map[string]any{"error": err.Error()}) + return false, supplemental + } + if err := al.bus.PublishOutboundMedia(ctx, bus.OutboundMediaMessage{Channel: opts.Channel, ChatID: opts.ChatID, Parts: []bus.MediaPart{{Type: "voice", Ref: ref, Filename: resp.Filename, ContentType: resp.ContentType}}}); err != nil { + logger.WarnCF("voice", "Failed to publish synthesized voice reply", map[string]any{"error": err.Error()}) + return false, supplemental + } + return true, supplemental +} + +func extractWrappedToolCalls(content string) []providers.ToolCall { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return nil + } + + lower := strings.ToLower(trimmed) + start := strings.Index(lower, "call>") + end := strings.LastIndex(lower, "all>") + if start == -1 || end == -1 || end <= start+5 { + return nil + } + + inner := strings.TrimSpace(trimmed[start+5 : end]) + if strings.HasPrefix(inner, ">") { + inner = strings.TrimSpace(strings.TrimPrefix(inner, ">")) + } + if inner == "" { + return nil + } + + type wrappedCall struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments"` + } + + var calls []wrappedCall + if err := json.Unmarshal([]byte(inner), &calls); err != nil || len(calls) == 0 { + // Try single object form. + var single wrappedCall + if err2 := json.Unmarshal([]byte(inner), &single); err2 != nil || strings.TrimSpace(single.Name) == "" { + return nil + } + calls = []wrappedCall{single} + } + + out := make([]providers.ToolCall, 0, len(calls)) + for i, c := range calls { + name := strings.TrimSpace(strings.ToLower(c.Name)) + switch name { + case "readfile": + name = "read_file" + case "writefile": + name = "write_file" + case "listdir": + name = "list_dir" + } + if name == "" { + continue + } + if c.Arguments == nil { + c.Arguments = map[string]any{} + } + out = append(out, providers.ToolCall{ + ID: fmt.Sprintf("wrapped_%d", i+1), + Type: "function", + Name: name, + Arguments: c.Arguments, + }) + } + if len(out) == 0 { + return nil + } + return out +} + +func preferToolCapableCandidates( + candidates []providers.FallbackCandidate, + model string, + messages []providers.Message, + hasToolCalls bool, +) ([]providers.FallbackCandidate, string, bool) { + if len(candidates) < 2 { + return nil, "", false + } + if !hasToolCalls && !messagesContainMedia(messages) { + return nil, "", false + } + if !isOpenRouterFreeCandidate(candidates[0]) { + return nil, "", false + } + + reordered := append([]providers.FallbackCandidate(nil), candidates...) + first := reordered[0] + reordered = append(reordered[1:], first) + return reordered, reordered[0].Model, true +} + +func messagesContainMedia(messages []providers.Message) bool { + for _, msg := range messages { + if len(msg.Media) > 0 { + return true + } + } + return false +} + +func isOpenRouterFreeCandidate(candidate providers.FallbackCandidate) bool { + return strings.EqualFold(strings.TrimSpace(candidate.Provider), "openrouter") && + strings.EqualFold(strings.TrimSpace(candidate.Model), "openrouter/free") +} + +func rewriteToolArguments(name string, args map[string]any, agent *AgentInstance, mediaRefs []string, store media.MediaStore) map[string]any { + switch strings.ToLower(strings.TrimSpace(name)) { + case "read_file": + return rewriteReadFileArgsFromMedia(args, mediaRefs, store) + case "host_preview": + return rewriteHostPreviewArgs(args, agent) + case "send_file": + return rewriteSendFileArgs(args, agent, mediaRefs, store) + case "message": + return rewriteMessageArgs(args) + default: + return args + } +} + +func rewriteHostPreviewArgs(args map[string]any, agent *AgentInstance) map[string]any { + if len(args) == 0 { + return args + } + if pathVal, _ := args["path"].(string); strings.TrimSpace(pathVal) != "" { + if info, err := os.Stat(strings.TrimSpace(pathVal)); err == nil && info.IsDir() { + return args + } + } + raw := rawArgumentString(args) + if raw == "" { + return args + } + if candidate := extractExistingPathFromRaw(raw, candidateSearchRoots(agent), true); candidate != "" { + args["path"] = candidate + if _, ok := args["entry"]; !ok && strings.Contains(strings.ToLower(raw), "index.html") { + args["entry"] = "index.html" + } + delete(args, "raw") + } + return args +} + +func rewriteSendFileArgs(args map[string]any, agent *AgentInstance, mediaRefs []string, store media.MediaStore) map[string]any { + args = rewriteReadFileArgsFromMedia(args, mediaRefs, store) + if len(args) == 0 { + return args + } + if pathVal, _ := args["path"].(string); strings.TrimSpace(pathVal) != "" { + if _, err := os.Stat(strings.TrimSpace(pathVal)); err == nil { + if _, ok := args["filename"]; !ok { + args["filename"] = filepath.Base(strings.TrimSpace(pathVal)) + } + delete(args, "raw") + return args + } + } + raw := rawArgumentString(args) + if raw == "" { + return args + } + if candidate := extractExistingPathFromRaw(raw, candidateSearchRoots(agent), false); candidate != "" { + args["path"] = candidate + if _, ok := args["filename"]; !ok { + args["filename"] = filepath.Base(candidate) + } + delete(args, "raw") + } + return args +} + +func rewriteMessageArgs(args map[string]any) map[string]any { + if len(args) == 0 { + return args + } + raw := rawArgumentString(args) + if raw == "" { + return args + } + for _, key := range []string{"channel", "chat_id", "content"} { + if value, _ := args[key].(string); strings.TrimSpace(value) == "" { + if decoded := extractSimpleStringField(raw, key); decoded != "" { + args[key] = decoded + } + } + } + if content, _ := args["content"].(string); strings.TrimSpace(content) != "" { + delete(args, "raw") + } + return args +} + +func rawArgumentString(args map[string]any) string { + if len(args) == 0 { + return "" + } + if raw, _ := args["raw"].(string); strings.TrimSpace(raw) != "" { + return strings.TrimSpace(raw) + } + buf, err := json.Marshal(args) + if err != nil { + return "" + } + return strings.TrimSpace(string(buf)) +} + +func extractSimpleStringField(raw, key string) string { + re := regexp.MustCompile(fmt.Sprintf(`"%s"\s*:\s*"((?:\\.|[^"])*)"`, regexp.QuoteMeta(key))) + match := re.FindStringSubmatch(raw) + if len(match) < 2 { + return "" + } + encoded := match[1] + var decoded string + if err := json.Unmarshal([]byte(`"`+encoded+`"`), &decoded); err == nil { + return strings.TrimSpace(decoded) + } + return strings.TrimSpace(strings.ReplaceAll(encoded, `\"`, `"`)) +} + +func extractExistingPathFromRaw(raw string, roots []string, wantDir bool) string { + pathPattern := regexp.MustCompile(`/(?:root|tmp|home)[^"'{}\s,]+`) + for _, match := range pathPattern.FindAllString(raw, -1) { + candidate := strings.TrimSpace(match) + if candidate == "" { + continue + } + if info, err := os.Stat(candidate); err == nil && info.IsDir() == wantDir { + return candidate + } + } + basePattern := regexp.MustCompile(`[A-Za-z0-9][A-Za-z0-9._-]{2,}`) + for _, token := range basePattern.FindAllString(raw, -1) { + if strings.Contains(token, ".") || strings.Contains(token, "-") || strings.Contains(token, "_") { + if candidate := searchPathByBaseName(token, roots, wantDir); candidate != "" { + return candidate + } + } + } + return "" +} + +func candidateSearchRoots(agent *AgentInstance) []string { + roots := []string{} + seen := map[string]bool{} + add := func(path string) { + path = strings.TrimSpace(path) + if path == "" || seen[path] { + return + } + if info, err := os.Stat(path); err == nil && info.IsDir() { + seen[path] = true + roots = append(roots, path) + } + } + if agent != nil { + add(agent.Workspace) + add(filepath.Join(agent.Workspace, "projects")) + parent := filepath.Dir(agent.Workspace) + add(parent) + add(filepath.Join(parent, "projects")) + grandparent := filepath.Dir(parent) + add(grandparent) + add(filepath.Join(grandparent, "projects")) + } + add("/root/.picoclaw/workspace") + add("/root/.picoclaw/workspace/projects") + return roots +} + +func searchPathByBaseName(name string, roots []string, wantDir bool) string { + for _, root := range roots { + var found string + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if info.IsDir() != wantDir { + return nil + } + if strings.EqualFold(info.Name(), name) { + found = path + return filepath.SkipDir + } + return nil + }) + if found != "" { + return found + } + } + return "" +} + +func rewriteReadFileArgsFromMedia(args map[string]any, mediaRefs []string, store media.MediaStore) map[string]any { + if len(args) == 0 || len(mediaRefs) == 0 || store == nil { + return args + } + + pathVal, _ := args["path"].(string) + pathVal = strings.TrimSpace(pathVal) + rawVal, _ := args["raw"].(string) + rawVal = strings.TrimSpace(rawVal) + if pathVal == "" && rawVal == "" { + return args + } + + type mediaCandidate struct { + ref string + path string + name string + } + cands := make([]mediaCandidate, 0, len(mediaRefs)) + for _, ref := range mediaRefs { + if !strings.HasPrefix(ref, "media://") { + continue + } + localPath, meta, err := store.ResolveWithMeta(ref) + if err != nil || strings.TrimSpace(localPath) == "" { + continue + } + name := strings.TrimSpace(meta.Filename) + if name == "" { + name = filepath.Base(localPath) + } + cands = append(cands, mediaCandidate{ref: ref, path: localPath, name: name}) + } + if len(cands) == 0 { + return args + } + + if rawVal != "" { + lowerRaw := strings.ToLower(rawVal) + for _, c := range cands { + if strings.Contains(lowerRaw, strings.ToLower(c.name)) || + strings.Contains(lowerRaw, strings.ToLower(filepath.Base(c.path))) || + strings.Contains(lowerRaw, strings.ToLower(c.path)) { + args["path"] = c.path + delete(args, "raw") + return args + } + } + if len(cands) == 1 { + args["path"] = cands[0].path + delete(args, "raw") + return args + } + } + + if pathVal == "" { + if len(cands) == 1 { + args["path"] = cands[0].path + } + return args + } + + if strings.HasPrefix(pathVal, "media://") { + for _, c := range cands { + if c.ref == pathVal { + args["path"] = c.path + return args + } + } + } + + base := strings.ToLower(filepath.Base(pathVal)) + for _, c := range cands { + if base != "" && strings.ToLower(filepath.Base(c.path)) == base { + args["path"] = c.path + return args + } + if base != "" && strings.ToLower(c.name) == base { + args["path"] = c.path + return args + } + } + + if len(cands) == 1 { + args["path"] = cands[0].path + } + + return args +} + +func effectiveConversationIterations(channel string, base int) int { + if base <= 0 { + base = 1 + } + switch strings.ToLower(strings.TrimSpace(channel)) { + case "telegram", "whatsapp", "whatsapp_native": + if base < 14 { + return 14 + } + } + return base +} + +func sanitizeLeakedToolPayload(content string) string { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return content + } + + lower := strings.ToLower(trimmed) + if strings.Contains(lower, "tool_call") || strings.Contains(lower, `{"tool_calls"`) { + return "" + } + + // Catch wrapper formats like CALL>[{...}]ALL> / OLCALL> + if strings.Contains(lower, "call>") || strings.Contains(lower, "all>") { + if strings.Contains(lower, `"name"`) && strings.Contains(lower, `"arguments"`) { + return "" + } + } + if strings.EqualFold(trimmed, "OLCALL>") { + return "" + } + + if strings.Contains(trimmed, `"name"`) && strings.Contains(trimmed, `"arguments"`) { + if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, ">") || strings.HasPrefix(lower, "call>") { + return "" + } + } + + return content +} + +func (al *AgentLoop) inferImplicitTelegramCommand(msg bus.InboundMessage, text string, agent *AgentInstance) string { + if msg.Channel != "telegram" || msg.Peer.Kind != "direct" { + return "" + } + + trimmed := strings.TrimSpace(text) + if trimmed == "" || commands.HasCommandPrefix(trimmed) { + return "" + } + + lower := strings.ToLower(trimmed) + switch lower { + case "gws", "gws?", "google workspace", "google workspace?", "gmail?", "mail?": + return "/check gws" + } + + if al.shouldRecallRecentPreview(lower, agent) || al.shouldRecallRecentPreviewFromConversation(lower, agent) { + return "/show previews" + } + + if !googleWorkspaceAvailable() { + return "" + } + + if recipient, ok := inferEmailSendRecipient(trimmed, lower); ok { + subject := "Pico test mail" + body := "Test mail sent by Pico." + return fmt.Sprintf("/exec gws gmail +send --to %q --subject %q --body %q", recipient, subject, body) + } + + if strings.Contains(lower, "email") || strings.Contains(lower, "emails") || strings.Contains(lower, "mail") || strings.Contains(lower, "mails") || strings.Contains(lower, "inbox") { + maxItems := extractRequestedCount(lower, 5) + query := "in:anywhere" + if strings.Contains(lower, "unread") { + query = "is:unread" + } + return fmt.Sprintf("/exec gws gmail +triage --max %d --query %q --format table", maxItems, query) + } + + if strings.Contains(lower, "agenda") || strings.Contains(lower, "calendar") || strings.Contains(lower, "events") { + args := []string{"/exec", "gws", "calendar", "+agenda", "--format", "table"} + switch { + case strings.Contains(lower, "today"): + args = append(args, "--today") + case strings.Contains(lower, "tomorrow"): + args = append(args, "--tomorrow") + case strings.Contains(lower, "week"): + args = append(args, "--week") + default: + args = append(args, "--days", fmt.Sprintf("%d", extractRequestedCount(lower, 3))) + } + return strings.Join(args, " ") + } + + if strings.Contains(lower, "drive") && (strings.Contains(lower, "file") || strings.Contains(lower, "files")) { + pageSize := extractRequestedCount(lower, 10) + params := fmt.Sprintf("{\"pageSize\":%d}", pageSize) + return fmt.Sprintf("/exec gws drive files list --params %q --format table", params) + } + + return "" +} + +func (al *AgentLoop) shouldRecallRecentPreview(lower string, agent *AgentInstance) bool { + if agent == nil || strings.TrimSpace(agent.Workspace) == "" { + return false + } + wantsURL := strings.Contains(lower, "url") || strings.Contains(lower, "urls") || strings.Contains(lower, "link") || strings.Contains(lower, "links") + wantsRecent := strings.Contains(lower, "recent") || strings.Contains(lower, "latest") || strings.Contains(lower, "most recent") || strings.Contains(lower, "current") + wantsHost := strings.Contains(lower, "host") || strings.Contains(lower, "serve") || strings.Contains(lower, "open") + if !wantsURL && !(wantsRecent && wantsHost) { + return false + } + wantsPreview := strings.Contains(lower, "preview") || strings.Contains(lower, "site") || strings.Contains(lower, "website") || strings.Contains(lower, "app") || strings.Contains(lower, "build") + if !wantsPreview { + return false + } + return len(al.recentPreviewInfos(agent)) > 0 +} + +func (al *AgentLoop) shouldRecallRecentPreviewFromConversation(lower string, agent *AgentInstance) bool { + if agent == nil || strings.TrimSpace(agent.Workspace) == "" { + return false + } + wantsURL := strings.Contains(lower, "url") || strings.Contains(lower, "urls") || strings.Contains(lower, "link") || strings.Contains(lower, "links") + wantsRecent := strings.Contains(lower, "recent") || strings.Contains(lower, "latest") || strings.Contains(lower, "most recent") || strings.Contains(lower, "current") + wantsHost := strings.Contains(lower, "host") || strings.Contains(lower, "serve") || strings.Contains(lower, "open") + if !wantsURL && !(wantsRecent && wantsHost) { + return false + } + hints := strings.Contains(lower, "talked") || strings.Contains(lower, "previous") || strings.Contains(lower, "built") || strings.Contains(lower, "that site") || strings.Contains(lower, "that website") || wantsRecent + if !hints { + return false + } + return len(al.recentPreviewInfos(agent)) > 0 +} + +func inferEmailSendRecipient(trimmed, lower string) (string, bool) { + if !strings.Contains(lower, "send") { + return "", false + } + if !(strings.Contains(lower, "mail") || strings.Contains(lower, "email")) { + return "", false + } + match := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`).FindString(trimmed) + if strings.TrimSpace(match) == "" { + return "", false + } + return match, true +} + +func extractRequestedCount(text string, fallback int) int { + match := regexp.MustCompile(`\b(\d{1,2})\b`).FindStringSubmatch(text) + if len(match) < 2 { + return fallback + } + value := fallback + fmt.Sscanf(match[1], "%d", &value) + if value <= 0 { + return fallback + } + if value > 50 { + return 50 + } + return value +} + +func googleWorkspaceAvailable() bool { + if _, err := exec.LookPath("gws"); err != nil { + return false + } + home, err := os.UserHomeDir() + if err != nil || strings.TrimSpace(home) == "" { + return false + } + _, err = os.Stat(filepath.Join(home, ".config", "gws", "credentials.json")) + return err == nil +} + +func shouldAutoRunShellInTelegram(msg bus.InboundMessage, text string) bool { + if msg.Channel != "telegram" || msg.Peer.Kind != "direct" { + return false + } + trimmed := strings.TrimSpace(text) + if trimmed == "" || commands.HasCommandPrefix(trimmed) { + return false + } + first := strings.ToLower(strings.Fields(trimmed)[0]) + allowed := map[string]bool{ + "gws": true, "gcloud": true, "tailscale": true, "systemctl": true, + "redis-cli": true, "curl": true, "ssh": true, "ls": true, + "cat": true, "test": true, "mkdir": true, "chmod": true, + "chown": true, "journalctl": true, "picoclaw": true, "hostname": true, + } + return allowed[first] +} + func mapCommandError(result commands.ExecuteResult) string { if result.Command == "" { return fmt.Sprintf("Failed to execute command: %v", result.Err) diff --git a/pkg/agent/loop_media.go b/pkg/agent/loop_media.go index 82547a008..d0008bd93 100644 --- a/pkg/agent/loop_media.go +++ b/pkg/agent/loop_media.go @@ -120,3 +120,45 @@ func resolveMediaRefs(messages []providers.Message, store media.MediaStore, maxS return result } + +// buildAttachmentContext builds explicit text context for attachments so +// non-inlineable files (for example PDFs) remain visible to the model. +func buildAttachmentContext(refs []string, store media.MediaStore) string { + if len(refs) == 0 { + return "" + } + + lines := make([]string, 0, len(refs)) + for _, ref := range refs { + if strings.HasPrefix(ref, "media://") && store != nil { + localPath, meta, err := store.ResolveWithMeta(ref) + if err == nil { + name := strings.TrimSpace(meta.Filename) + if name == "" { + name = "attachment" + } + mime := strings.TrimSpace(meta.ContentType) + detail := "- " + name + if mime != "" { + detail += " (" + mime + ")" + } + detail += " [ref: " + ref + "]" + if strings.TrimSpace(localPath) != "" { + detail += " [local_path: " + localPath + "]" + } + lines = append(lines, detail) + continue + } + } + lines = append(lines, "- "+ref) + } + + if len(lines) == 0 { + return "" + } + + return "Attached files in this turn:\n" + strings.Join(lines, "\n") + "\n\n" + + "Attachment handling rules:\n" + + "- If an attachment is listed, do not ask the user for a file path.\n" + + "- Use local_path or ref from this list when reading/processing files." +} diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 2e456fa60..11c898f4a 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1116,3 +1116,43 @@ func TestResolveMediaRefs_UsesMetaContentType(t *testing.T) { t.Fatalf("expected jpeg prefix, got %q", result[0].Media[0][:30]) } } + +func TestMergeVoiceTranscriptions(t *testing.T) { + got := mergeVoiceTranscriptions("[voice]", []string{"build a landing page with a pricing section"}) + want := "Voice note transcript: build a landing page with a pricing section" + if got != want { + t.Fatalf("mergeVoiceTranscriptions() = %q, want %q", got, want) + } + + got = mergeVoiceTranscriptions("keep going\n[voice]", []string{"retrieve 5 of my emails"}) + want = "keep going\nVoice note transcript: retrieve 5 of my emails" + if got != want { + t.Fatalf("mergeVoiceTranscriptions() mixed content = %q, want %q", got, want) + } +} + +func TestBuildDynamicContextIncludesConversationalOperatorMode(t *testing.T) { + tmpDir, err := os.MkdirTemp("", "agent-context-*") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + defer os.RemoveAll(tmpDir) + + cb := NewContextBuilder(tmpDir) + ctx := cb.buildDynamicContext("telegram", "5533009291") + if !strings.Contains(ctx, "Conversational Operator Mode") { + t.Fatalf("expected conversational operator context, got %q", ctx) + } + if !strings.Contains(ctx, "Treat voice-note transcriptions") { + t.Fatalf("expected voice-note guidance, got %q", ctx) + } +} + +func TestEffectiveConversationIterations(t *testing.T) { + if got := effectiveConversationIterations("telegram", 10); got != 14 { + t.Fatalf("telegram iterations = %d, want 14", got) + } + if got := effectiveConversationIterations("cli", 10); got != 10 { + t.Fatalf("cli iterations = %d, want 10", got) + } +} diff --git a/pkg/agent/loop_toolcapable_test.go b/pkg/agent/loop_toolcapable_test.go new file mode 100644 index 000000000..dc0561584 --- /dev/null +++ b/pkg/agent/loop_toolcapable_test.go @@ -0,0 +1,39 @@ +package agent + +import ( + "testing" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestPreferToolCapableCandidates_ReordersOpenRouterFreeForMediaTurns(t *testing.T) { + candidates := []providers.FallbackCandidate{ + {Provider: "openrouter", Model: "openrouter/free"}, + {Provider: "gemini", Model: "gemini-2.5-flash"}, + {Provider: "deepseek", Model: "deepseek-chat"}, + } + messages := []providers.Message{{Role: "user", Content: "[image: photo]", Media: []string{"media://x"}}} + + reordered, model, ok := preferToolCapableCandidates(candidates, candidates[0].Model, messages, false) + if !ok { + t.Fatal("expected reorder for media turn") + } + if reordered[0].Provider != "gemini" || reordered[0].Model != "gemini-2.5-flash" { + t.Fatalf("first candidate = %s/%s, want gemini/gemini-2.5-flash", reordered[0].Provider, reordered[0].Model) + } + if model != "gemini-2.5-flash" { + t.Fatalf("model = %q, want gemini-2.5-flash", model) + } +} + +func TestPreferToolCapableCandidates_LeavesPlainTextTurnsAlone(t *testing.T) { + candidates := []providers.FallbackCandidate{ + {Provider: "openrouter", Model: "openrouter/free"}, + {Provider: "gemini", Model: "gemini-2.5-flash"}, + } + messages := []providers.Message{{Role: "user", Content: "hello"}} + + if reordered, model, ok := preferToolCapableCandidates(candidates, candidates[0].Model, messages, false); ok || reordered != nil || model != "" { + t.Fatalf("expected no reorder, got ok=%v model=%q reordered=%v", ok, model, reordered) + } +} diff --git a/pkg/agent/profile_workspace.go b/pkg/agent/profile_workspace.go new file mode 100644 index 000000000..d97d9dcf4 --- /dev/null +++ b/pkg/agent/profile_workspace.go @@ -0,0 +1,232 @@ +package agent + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/routing" +) + +var profilePathSanitizer = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +type profileMetadata struct { + ProfileKey string `json:"profile_key"` + BaseAgentID string `json:"base_agent_id"` + Mode string `json:"mode"` + CreatedAt string `json:"created_at"` + Isolated bool `json:"isolated"` + MutableFiles []string `json:"mutable_files"` +} + +func sanitizeProfilePathPart(raw string) string { + trimmed := strings.TrimSpace(strings.ToLower(raw)) + if trimmed == "" { + return "anonymous" + } + safe := profilePathSanitizer.ReplaceAllString(trimmed, "_") + safe = strings.Trim(safe, "._-") + if safe == "" { + return "anonymous" + } + return safe +} + +func profileWorkspacePath(baseWorkspace, profileKey string) string { + return filepath.Join(baseWorkspace, "profiles", sanitizeProfilePathPart(profileKey)) +} + +func ensureProfileWorkspace(baseWorkspace, profileWorkspace, baseAgentID, profileKey string) error { + if err := os.MkdirAll(profileWorkspace, 0o755); err != nil { + return err + } + for _, dir := range []string{"memory", "sessions", "state", "logs", "tmp", "exports"} { + if err := os.MkdirAll(filepath.Join(profileWorkspace, dir), 0o755); err != nil { + return err + } + } + + rootFiles := []string{ + "AGENTS.md", + "SOUL.md", + "USER.md", + "IDENTITY.md", + "HEARTBEAT.md", + "README.txt", + "defaults.sh", + "wsl-codex-exec", + "node-exec", + "node-ssh", + } + for _, name := range rootFiles { + if err := copyFileIfMissing(filepath.Join(baseWorkspace, name), filepath.Join(profileWorkspace, name)); err != nil { + return err + } + } + if err := copyDirIfMissing(filepath.Join(baseWorkspace, "skills"), filepath.Join(profileWorkspace, "skills")); err != nil { + return err + } + + profileFile := filepath.Join(profileWorkspace, "PROFILE.json") + if _, err := os.Stat(profileFile); err == nil { + return nil + } + meta := profileMetadata{ + ProfileKey: profileKey, + BaseAgentID: baseAgentID, + Mode: "isolated-user-profile", + CreatedAt: time.Now().Format(time.RFC3339), + Isolated: true, + MutableFiles: []string{ + "PROFILE.json", + "memory/MEMORY.md", + "memory/YYYYMM/YYYYMMDD.md", + "skills/*", + "sessions/*", + "state/*", + }, + } + data, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + return os.WriteFile(profileFile, append(data, '\n'), 0o644) +} + +func copyFileIfMissing(src, dst string) error { + if _, err := os.Stat(dst); err == nil { + return nil + } + info, err := os.Stat(src) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if info.IsDir() { + return nil + } + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_EXCL|os.O_WRONLY, info.Mode().Perm()) + if err != nil { + if os.IsExist(err) { + return nil + } + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return nil +} + +func copyDirIfMissing(src, dst string) error { + if _, err := os.Stat(dst); err == nil { + return nil + } + info, err := os.Stat(src) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + if !info.IsDir() { + return nil + } + return filepath.Walk(src, func(path string, entryInfo os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if entryInfo.IsDir() { + return os.MkdirAll(target, entryInfo.Mode().Perm()) + } + return copyFileIfMissing(path, target) + }) +} + +func cloneSubagentsConfig(src *config.SubagentsConfig) *config.SubagentsConfig { + if src == nil { + return nil + } + cloned := &config.SubagentsConfig{ + AllowAgents: slices.Clone(src.AllowAgents), + } + if src.Model != nil { + cloned.Model = &config.AgentModelConfig{ + Primary: src.Model.Primary, + Fallbacks: slices.Clone(src.Model.Fallbacks), + } + } + return cloned +} + +func (r *AgentRegistry) GetOrCreateProfileAgent(baseAgentID, profileKey string) (*AgentInstance, bool, error) { + baseID := routing.NormalizeAgentID(baseAgentID) + normalizedProfile := strings.TrimSpace(strings.ToLower(profileKey)) + if normalizedProfile == "" { + return nil, false, fmt.Errorf("profile key is required") + } + cacheKey := baseID + "|" + normalizedProfile + + r.mu.RLock() + if agent, ok := r.profileAgents[cacheKey]; ok { + r.mu.RUnlock() + return agent, false, nil + } + baseAgent, ok := r.agents[baseID] + r.mu.RUnlock() + if !ok || baseAgent == nil { + return nil, false, fmt.Errorf("base agent %s not found", baseID) + } + + workspace := profileWorkspacePath(baseAgent.Workspace, normalizedProfile) + if err := ensureProfileWorkspace(baseAgent.Workspace, workspace, baseAgent.ID, normalizedProfile); err != nil { + return nil, false, err + } + + agentCfg := &config.AgentConfig{ + ID: baseAgent.ID, + Name: baseAgent.Name, + Workspace: workspace, + Skills: slices.Clone(baseAgent.SkillsFilter), + Subagents: cloneSubagentsConfig(baseAgent.Subagents), + Model: &config.AgentModelConfig{ + Primary: baseAgent.Model, + Fallbacks: slices.Clone(baseAgent.Fallbacks), + }, + } + instance := NewAgentInstance(agentCfg, &r.cfg.Agents.Defaults, r.cfg, r.provider) + + r.mu.Lock() + defer r.mu.Unlock() + if agent, ok := r.profileAgents[cacheKey]; ok { + return agent, false, nil + } + r.profileAgents[cacheKey] = instance + return instance, true, nil +} diff --git a/pkg/agent/registry.go b/pkg/agent/registry.go index 0e7973dc3..ad7c02790 100644 --- a/pkg/agent/registry.go +++ b/pkg/agent/registry.go @@ -12,9 +12,12 @@ import ( // AgentRegistry manages multiple agent instances and routes messages to them. type AgentRegistry struct { - agents map[string]*AgentInstance - resolver *routing.RouteResolver - mu sync.RWMutex + agents map[string]*AgentInstance + profileAgents map[string]*AgentInstance + resolver *routing.RouteResolver + cfg *config.Config + provider providers.LLMProvider + mu sync.RWMutex } // NewAgentRegistry creates a registry from config, instantiating all agents. @@ -23,8 +26,11 @@ func NewAgentRegistry( provider providers.LLMProvider, ) *AgentRegistry { registry := &AgentRegistry{ - agents: make(map[string]*AgentInstance), - resolver: routing.NewRouteResolver(cfg), + agents: make(map[string]*AgentInstance), + profileAgents: make(map[string]*AgentInstance), + resolver: routing.NewRouteResolver(cfg), + cfg: cfg, + provider: provider, } agentConfigs := cfg.Agents.List @@ -112,6 +118,11 @@ func (r *AgentRegistry) ForEachTool(name string, fn func(tools.Tool)) { fn(t) } } + for _, agent := range r.profileAgents { + if t, ok := agent.Tools.Get(name); ok { + fn(t) + } + } } // GetDefaultAgent returns the default agent instance. diff --git a/pkg/channels/control_plane_dashboard.go b/pkg/channels/control_plane_dashboard.go new file mode 100644 index 000000000..7ef9d0eac --- /dev/null +++ b/pkg/channels/control_plane_dashboard.go @@ -0,0 +1,4327 @@ +package channels + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/logger" +) + +var controlPlaneTemplate = template.Must(template.New("control_plane_dashboard").Parse(` + + + + + PicoClaw Control Plane + + + + + + +
+
+
+ +
+

PicoClaw Multi-Agent Control Plane

+ gateway={{.Address}} • boot={{.GeneratedAt}} +
+
+
+ loading + + + +
+
+ + + +
+
+
+
+

Overview

+

Cluster-wide runtime, service health, pending setup, and quick validation controls.

+
+
+
+
+
+
+
+ + Live orchestration + backend-driven fixes + mobile ready +
+

PicoClaw is now one responsive surface for restore, diagnosis, and repair.

+
Use the security page for LLM diagnosis and safe fix buttons, the secure terminal for authenticated operator access, and the backend test console for channel/Telegram/Codex workflow validation without leaving the control plane.
+
+
+
+
Operator modeTouch-firstcards, quick actions, dense mobile layouts
+
Recovery modeLLM + Safe Fixdiagnose, sync scripts, validate secure web
+
Terminal policyLogin + TS + TLSlocked unless tailscale + secure origin
+
Codex pathPico fallbacklocal agent fallback if remote SSH path fails
+
+
+
+
+
+

Pending Setup / Auth Tasks

+
    +
    +
    +

    Cluster Test Controls

    +
    + + + + + +
    +
    +
    +
    +
    + +
    +
    +
    +

    Multi-Agent Runtime

    +

    Agent registry, heartbeat, queue depth, active jobs, capabilities, and node assignment.

    +
    +
    +
    +
    +

    Per-Agent Logs

    +
    +
    +
    + +
    +
    +
    +

    Nodes / Infrastructure

    +

    VPS, PICO, PC workers, storage nodes, reachability, SSH readiness, and recent failures.

    +
    +
    +
    +
    + +
    +
    +
    +

    Tailscale / Remote Execution

    +

    Node-to-node path matrix, blockers, ACL/tag mismatch warnings, and setup completeness.

    +
    +
    +
    +
    +
    +

    Warnings & Required Actions

    +
      +
      +
      +
      + +
      +
      +
      +

      Security / Auth / Secure Web

      +

      High-level security controls, backend-driven diagnostics, Google/Tailscale readiness, and operator-facing secure access options.

      +
      +
      +
      +
      +
      +

      Security Domains

      +
      +
      +
      +

      Interactive Diagnostics

      +
      + + + + + + + + +
      +
      +
      +
      +
      +
      +
      +

      Findings / Fix Queue

      +
      +
      +
      +

      Web / Auth Access Matrix

      +
      +
      +
      +
      +
      +

      Guided Checklist

      +
        +
        +
        +

        Provider / Channel Auth State

        +
        +
        +
        +
        + +
        +
        +
        +

        Job Queue / Execution

        +

        Queued/running/failed/completed jobs, retries, timeline, artifacts, and child task metadata.

        +
        +
        +
        +
        +
        +

        Job Timeline / Logs

        +
        +
        +
        + +
        +
        +
        +

        Backend Test Chat UI

        +

        Prompt console + workflow debugger + integration tester + agent playground.

        +
        +
        +
        +
        +
        +

        Send Test

        +
        +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + +
        +
        + + + + +
        + + + + +
        +
        +
        +
        +
        +
        + +
        +
        +
        +

        Media Workflows

        +

        Drive ingest, transcript/SRT, cut suggestions, FFmpeg readiness, and Vegas-oriented outputs.

        +
        +
        +
        +
        +

        Media Pipeline Status

        +
        +
        +
        +

        Templates / Sample Manifests

        +
        +
        +
        +
        + +
        +
        +
        +

        App / Site Generator

        +

        Generation requests, template selection, build status, deploy targets, and child-agent contribution.

        +
        +
        +
        +
        +

        Requests

        +
        +
        +
        +

        Build / Deploy Status

        +
        +
        +
        +
        + +
        +
        +
        +

        Secure Terminal / Operator Console

        +

        Interactive command access for operators. This surface is intentionally blocked unless the request is on tailscale, over HTTPS, and logged in.

        +
        +
        +
        +
        +
        +

        Access Policy

        +
        +
        +
        +
        +

        Login

        +
        +

        Use the terminal login password only from the hardened tailscale HTTPS surface. Public IP + plain HTTP is rejected.

        +
        + + +
        + + +
        +
        +
        +
        +
        +
        +
        +
        +
        + Operator Terminal +
        Runs in the Pico workspace with short timeout and session history.
        +
        +
        +
        +
        Terminal locked.
        +
        + + + +
        +
        +
        + +
        +
        +
        +

        Logs / Artifacts

        +

        Searchable operational logs, artifacts browser, and latest failures.

        +
        +
        +
        + + + +
        +
        +
        +

        Recent Logs

        +
        +
        +
        +

        Artifacts

        +
        +
        +
        +
        +
        +
        + + + +`)) + +type controlPlanePageData struct { + Address string + GeneratedAt string +} + +type controlPlaneStatus struct { + GeneratedAt string `json:"generated_at"` + Gateway string `json:"gateway"` + Summary controlSummary `json:"summary"` + Services []controlService `json:"services"` + Agents []controlAgent `json:"agents"` + Nodes []controlNode `json:"nodes"` + Tailscale controlTailscale `json:"tailscale"` + Auth controlAuth `json:"auth"` + Security controlSecurity `json:"security"` + Jobs controlJobs `json:"jobs"` + Media controlMedia `json:"media"` + AppGenerator controlAppGenerator `json:"app_generator"` + Logs controlLogs `json:"logs"` + Channels []controlChannel `json:"channels"` + Webhooks []controlWebhook `json:"webhooks"` + PendingSetup []controlChecklistItem `json:"pending_setup"` +} + +type controlSummary struct { + Nodes int `json:"nodes"` + Agents int `json:"agents"` + JobsTotal int `json:"jobs_total"` + PendingSetup int `json:"pending_setup"` + DegradedServices int `json:"degraded_services"` +} + +type controlService struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Uptime string `json:"uptime"` + LastError string `json:"last_error"` + LastSuccess string `json:"last_success"` +} + +type controlAgent struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + State string `json:"state"` + ActiveJob string `json:"active_job"` + QueueDepth int `json:"queue_depth"` + LastHeartbeat string `json:"last_heartbeat"` + Capabilities []string `json:"capabilities"` + AssignedNode string `json:"assigned_node"` + ExecutionTarget string `json:"execution_target"` + Concurrency int `json:"concurrency"` + Logs []string `json:"logs"` +} + +type controlNode struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + Tags []string `json:"tags"` + Role string `json:"role"` + TailscaleIP string `json:"tailscale_ip"` + SSHStatus string `json:"ssh_status"` + Reachability string `json:"reachability"` + LastHeartbeat string `json:"last_heartbeat"` + ExposedServices []string `json:"exposed_services"` + StorageUsage string `json:"storage_usage"` + CPUMemory string `json:"cpu_memory"` + RecentFailures string `json:"recent_failures"` + Online bool `json:"online"` + SSHEnabled bool `json:"ssh_enabled"` + WorkerReady bool `json:"worker_ready"` +} + +type controlTailscale struct { + Paths []controlPath `json:"paths"` + Warnings []string `json:"warnings"` +} + +type controlPath struct { + From string `json:"from"` + To string `json:"to"` + Status string `json:"status"` + Reason string `json:"reason"` + TailscaleOnline bool `json:"tailscale_online"` + SSHEnabled bool `json:"ssh_enabled"` + TagState string `json:"tag_state"` + WorkerRegistered bool `json:"worker_registered"` +} + +type controlAuth struct { + Checklist []controlChecklistItem `json:"checklist"` + Providers []controlAuthProvider `json:"providers"` +} + +type controlSecurity struct { + Summary controlSecuritySummary `json:"summary"` + Domains []controlSecurityDomain `json:"domains"` + Findings []controlSecurityFinding `json:"findings"` + Access []controlSecurityAccess `json:"access"` + Diagnosis controlSecurityDiagnosis `json:"diagnosis"` +} + +type controlSecuritySummary struct { + Overall string `json:"overall"` + Score int `json:"score"` + Critical int `json:"critical"` + Warn int `json:"warn"` + AuthReady int `json:"auth_ready"` + SecureWeb bool `json:"secure_web"` + LLMReady bool `json:"llm_ready"` +} + +type controlSecurityDomain struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Score int `json:"score"` + Detail string `json:"detail"` + Signals []string `json:"signals"` +} + +type controlSecurityFinding struct { + Severity string `json:"severity"` + Area string `json:"area"` + Title string `json:"title"` + Detail string `json:"detail"` + FixHint string `json:"fix_hint"` +} + +type controlSecurityAccess struct { + Name string `json:"name"` + URL string `json:"url"` + Status string `json:"status"` + Auth string `json:"auth"` + Exposure string `json:"exposure"` + Notes string `json:"notes"` +} + +type controlSecurityDiagnosis struct { + Status string `json:"status"` + LastRunAt string `json:"last_run_at"` + Summary string `json:"summary"` + Source string `json:"source"` + LastError string `json:"last_error"` + LastJobID string `json:"last_job_id"` +} + +type controlTerminalStatus struct { + Allowed bool `json:"allowed"` + Authenticated bool `json:"authenticated"` + Secure bool `json:"secure"` + Tailscale bool `json:"tailscale"` + LoginConfigured bool `json:"login_configured"` + Reason string `json:"reason"` + Host string `json:"host"` + History []controlTerminalEntry `json:"history"` + Suggestions []string `json:"suggestions"` +} + +type controlTerminalEntry struct { + At string `json:"at"` + Command string `json:"command"` + Output string `json:"output"` + ExitCode int `json:"exit_code"` + Status string `json:"status"` +} + +type controlChecklistItem struct { + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` +} + +type controlAuthProvider struct { + Provider string `json:"provider"` + AuthMethod string `json:"auth_method"` + Status string `json:"status"` + Account string `json:"account"` + ExpiresAt string `json:"expires_at"` +} + +type controlJobs struct { + Queued int `json:"queued"` + Running int `json:"running"` + Failed int `json:"failed"` + Completed int `json:"completed"` + Retries int `json:"retries"` + Items []controlJob `json:"items"` +} + +type controlJob struct { + ID string `json:"id"` + JobType string `json:"job_type"` + State string `json:"state"` + AgentID string `json:"agent_id"` + AssignedWorker string `json:"assigned_worker"` + StartTime string `json:"start_time"` + Duration string `json:"duration"` + Artifacts []string `json:"artifacts"` + ManifestSummary string `json:"manifest_summary"` + Timeline []controlJobEvent `json:"timeline"` + Payload map[string]any `json:"-"` + CreatedAt time.Time `json:"-"` + StartedAt time.Time `json:"-"` + FinishedAt time.Time `json:"-"` + RetryCount int `json:"-"` +} + +type controlJobEvent struct { + At string `json:"at"` + Status string `json:"status"` + Message string `json:"message"` +} + +type controlChatMessage struct { + Role string `json:"role"` + Content string `json:"content"` + CreatedAt string `json:"created_at"` +} + +type controlMedia struct { + Pipeline []controlPipelineStep `json:"pipeline"` + Templates []controlTemplate `json:"templates"` +} + +type controlPipelineStep struct { + Step string `json:"step"` + Status string `json:"status"` + Detail string `json:"detail"` +} + +type controlTemplate struct { + Name string `json:"name"` + SampleManifest string `json:"sample_manifest"` +} + +type controlAppGenerator struct { + Requests []controlAppRequest `json:"requests"` + Status []controlAppStatus `json:"status"` +} + +type controlAppRequest struct { + ID string `json:"id"` + ProjectType string `json:"project_type"` + Template string `json:"template"` + DeployTarget string `json:"deploy_target"` + Status string `json:"status"` +} + +type controlAppStatus struct { + Workspace string `json:"workspace"` + PreviewURL string `json:"preview_url"` + BuildStatus string `json:"build_status"` + TestStatus string `json:"test_status"` + ChildAgents []string `json:"child_agents"` +} + +type controlLogs struct { + Artifacts []controlArtifact `json:"artifacts"` +} + +type controlArtifact struct { + Path string `json:"path"` + Kind string `json:"kind"` + UpdatedAt string `json:"updated_at"` + Size string `json:"size"` +} + +type controlChannel struct { + Name string `json:"name"` + Running bool `json:"running"` + QueueDepth int `json:"queue_depth"` +} + +type controlWebhook struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type controlPlaneState struct { + mu sync.Mutex + address string + startedAt time.Time + nextJob int + jobs map[string]*controlJob + jobOrder []string + chatHistory []controlChatMessage + actionLog []string + sshCache map[string]sshProbe + diagnosis controlSecurityDiagnosis + terminalHistory []controlTerminalEntry + terminalSessions map[string]time.Time +} + +type controlJobRequest struct { + Prompt string `json:"prompt"` + AgentID string `json:"agent_id"` + ExecutionTarget string `json:"execution_target"` + WorkflowTemplate string `json:"workflow_template"` + TestType string `json:"test_type"` + DryRun bool `json:"dry_run"` + Payload map[string]any `json:"payload"` +} + +type sshProbe struct { + At time.Time + Status string + Detail string +} + +var controlPlaneStates sync.Map + +func getControlPlaneState(m *Manager) *controlPlaneState { + if v, ok := controlPlaneStates.Load(m); ok { + return v.(*controlPlaneState) + } + st := &controlPlaneState{ + startedAt: time.Now(), + jobs: make(map[string]*controlJob), + sshCache: make(map[string]sshProbe), + terminalSessions: make(map[string]time.Time), + } + seedControlPlaneState(st) + actual, _ := controlPlaneStates.LoadOrStore(m, st) + return actual.(*controlPlaneState) +} + +func seedControlPlaneState(st *controlPlaneState) { + j1 := &controlJob{ + ID: "job-seed-media-01", + JobType: "media_job", + State: "completed", + AgentID: "main", + AssignedWorker: "pico", + Artifacts: []string{"transcript.srt", "cuts.json"}, + ManifestSummary: "drive_ingest -> transcribe -> srt -> cut_suggestions", + CreatedAt: time.Now().Add(-40 * time.Minute), + StartedAt: time.Now().Add(-38 * time.Minute), + FinishedAt: time.Now().Add(-32 * time.Minute), + Timeline: []controlJobEvent{ + {At: time.Now().Add(-38 * time.Minute).Format(time.RFC3339), Status: "running", Message: "ingest started"}, + {At: time.Now().Add(-36 * time.Minute).Format(time.RFC3339), Status: "running", Message: "transcription complete"}, + {At: time.Now().Add(-33 * time.Minute).Format(time.RFC3339), Status: "completed", Message: "srt + cut suggestions ready"}, + }, + } + j2 := &controlJob{ + ID: "job-seed-app-01", + JobType: "app_generation", + State: "failed", + AgentID: "main", + AssignedWorker: "pc", + Artifacts: []string{"build.log"}, + ManifestSummary: "react-vite fullstack template", + CreatedAt: time.Now().Add(-70 * time.Minute), + StartedAt: time.Now().Add(-68 * time.Minute), + FinishedAt: time.Now().Add(-67 * time.Minute), + Timeline: []controlJobEvent{ + {At: time.Now().Add(-68 * time.Minute).Format(time.RFC3339), Status: "running", Message: "build started"}, + {At: time.Now().Add(-67 * time.Minute).Format(time.RFC3339), Status: "failed", Message: "missing deploy credentials"}, + }, + } + st.jobs[j1.ID] = j1 + st.jobs[j2.ID] = j2 + st.jobOrder = append(st.jobOrder, j1.ID, j2.ID) + st.chatHistory = append(st.chatHistory, + controlChatMessage{Role: "system", Content: "Control plane booted. Seeded media/app jobs for visibility.", CreatedAt: time.Now().Add(-70 * time.Minute).Format(time.RFC3339)}, + ) +} + +func (m *Manager) registerControlPlaneRoutes(addr string) { + st := getControlPlaneState(m) + st.mu.Lock() + st.address = addr + st.mu.Unlock() + + m.mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := controlPlaneTemplate.Execute(w, controlPlanePageData{ + Address: addr, + GeneratedAt: time.Now().Format(time.RFC3339), + }); err != nil { + logger.ErrorCF("channels", "control plane render failed", map[string]any{"error": err.Error()}) + http.Error(w, "control plane render failed", http.StatusInternalServerError) + return + } + }) + + m.mux.HandleFunc("GET /api/control-plane/status", func(w http.ResponseWriter, r *http.Request) { + status := m.buildControlPlaneStatus(addr) + respondJSON(w, http.StatusOK, status) + }) + + m.mux.HandleFunc("GET /api/control-plane/test-chat/history", func(w http.ResponseWriter, r *http.Request) { + state := getControlPlaneState(m) + state.mu.Lock() + history := append([]controlChatMessage(nil), state.chatHistory...) + state.mu.Unlock() + respondJSON(w, http.StatusOK, map[string]any{"messages": history}) + }) + + m.mux.HandleFunc("POST /api/control-plane/test-chat", func(w http.ResponseWriter, r *http.Request) { + job, err := m.enqueueControlPlaneJob(r.Body) + if err != nil { + respondJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()}) + return + } + respondJSON(w, http.StatusOK, map[string]any{"job_id": job.ID, "state": job.State}) + }) + + m.mux.HandleFunc("POST /api/control-plane/action", func(w http.ResponseWriter, r *http.Request) { + var req struct { + Action string `json:"action"` + Target string `json:"target"` + Payload map[string]any `json:"payload"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + respondJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid request"}) + return + } + result, err := m.handleControlPlaneAction(req.Action, req.Target, req.Payload) + if err != nil { + respondJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error(), "result": result}) + return + } + respondJSON(w, http.StatusOK, result) + }) + + m.mux.HandleFunc("GET /api/control-plane/logs", func(w http.ResponseWriter, r *http.Request) { + source := strings.TrimSpace(r.URL.Query().Get("source")) + if source == "" { + source = "heartbeat" + } + search := strings.TrimSpace(r.URL.Query().Get("search")) + lines := m.loadLogs(source) + if search != "" { + filtered := make([]string, 0, len(lines)) + needle := strings.ToLower(search) + for _, line := range lines { + if strings.Contains(strings.ToLower(line), needle) { + filtered = append(filtered, line) + } + } + lines = filtered + } + respondJSON(w, http.StatusOK, map[string]any{"source": source, "lines": lines}) + }) + + m.mux.HandleFunc("GET /api/control-plane/terminal/status", func(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, m.buildTerminalStatus(r)) + }) + + m.mux.HandleFunc("POST /api/control-plane/terminal/login", func(w http.ResponseWriter, r *http.Request) { + status, err := m.handleTerminalLogin(w, r) + if err != nil { + respondJSON(w, http.StatusForbidden, map[string]any{"error": err.Error(), "status": status}) + return + } + respondJSON(w, http.StatusOK, status) + }) + + m.mux.HandleFunc("POST /api/control-plane/terminal/logout", func(w http.ResponseWriter, r *http.Request) { + m.handleTerminalLogout(w, r) + respondJSON(w, http.StatusOK, m.buildTerminalStatus(r)) + }) + + m.mux.HandleFunc("POST /api/control-plane/terminal/exec", func(w http.ResponseWriter, r *http.Request) { + result, err := m.handleTerminalExec(r) + if err != nil { + respondJSON(w, http.StatusForbidden, map[string]any{"error": err.Error(), "result": result}) + return + } + respondJSON(w, http.StatusOK, result) + }) +} + +func (m *Manager) buildControlPlaneStatus(addr string) controlPlaneStatus { + st := getControlPlaneState(m) + channels, webhooks := m.snapshotChannelsAndWebhooks() + nodes, tailscale := m.buildNodesAndTailscale(st) + authState, pending := m.buildAuthState(nodes) + jobs := m.snapshotJobs(st) + heartbeatSummary := m.heartbeatSummary() + services := m.buildServiceCards(channels, authState, heartbeatSummary, jobs, st) + security := m.buildSecurityState(addr, nodes, tailscale, authState, services, pending, st) + agents := m.buildAgents(jobs, heartbeatSummary) + mediaState := m.buildMediaState() + appState := m.buildAppGeneratorState() + logsState := controlLogs{Artifacts: m.collectArtifacts()} + + degraded := 0 + for _, s := range services { + if s.Status == "warn" || s.Status == "error" { + degraded++ + } + } + + return controlPlaneStatus{ + GeneratedAt: time.Now().Format(time.RFC3339), + Gateway: addr, + Summary: controlSummary{Nodes: len(nodes), Agents: len(agents), JobsTotal: len(jobs.Items), PendingSetup: len(pending), DegradedServices: degraded}, + Services: services, + Agents: agents, + Nodes: nodes, + Tailscale: tailscale, + Auth: authState, + Security: security, + Jobs: jobs, + Media: mediaState, + AppGenerator: appState, + Logs: logsState, + Channels: channels, + Webhooks: webhooks, + PendingSetup: pending, + } +} + +func (m *Manager) snapshotChannelsAndWebhooks() ([]controlChannel, []controlWebhook) { + m.mu.RLock() + defer m.mu.RUnlock() + + channels := make([]controlChannel, 0, len(m.channels)) + webhooks := make([]controlWebhook, 0, len(m.channels)) + + for name, ch := range m.channels { + queueDepth := 0 + if w := m.workers[name]; w != nil { + queueDepth = len(w.queue) + len(w.mediaQueue) + } + channels = append(channels, controlChannel{Name: name, Running: ch.IsRunning(), QueueDepth: queueDepth}) + if wh, ok := ch.(WebhookHandler); ok { + webhooks = append(webhooks, controlWebhook{Name: name, Path: wh.WebhookPath()}) + } + } + + sort.Slice(channels, func(i, j int) bool { return channels[i].Name < channels[j].Name }) + sort.Slice(webhooks, func(i, j int) bool { return webhooks[i].Name < webhooks[j].Name }) + return channels, webhooks +} + +func (m *Manager) buildServiceCards( + channels []controlChannel, + authState controlAuth, + heartbeat heartbeatStatus, + jobs controlJobs, + st *controlPlaneState, +) []controlService { + chState := map[string]bool{} + for _, ch := range channels { + chState[ch.Name] = ch.Running + } + + workspace := m.config.WorkspacePath() + _, codexErr := os.Stat(filepath.Join(workspace, "wsl-codex-exec")) + _, gwsCredErr := os.Stat(filepath.Join(homeDir(), ".config", "gws", "credentials.json")) + + uptime := time.Since(st.startedAt).Round(time.Second).String() + + services := []controlService{ + {ID: "picoclaw-core", Name: "PicoClaw core", Status: "ok", Uptime: uptime, LastError: heartbeat.LastError, LastSuccess: heartbeat.LastSuccess}, + {ID: "telegram", Name: "Telegram", Status: boolState(chState["telegram"], "warn"), Uptime: uptime, LastError: heartbeat.LastError, LastSuccess: heartbeat.LastSuccess}, + {ID: "whatsapp", Name: "WhatsApp", Status: boolState(chState["whatsapp"] || chState["whatsapp_native"], "warn"), Uptime: uptime, LastError: heartbeat.LastError, LastSuccess: heartbeat.LastSuccess}, + {ID: "redis", Name: "Redis", Status: envState("REDIS_URL"), Uptime: "n/a", LastError: "", LastSuccess: ""}, + {ID: "worker-api", Name: "worker API(s)", Status: envState("WORKER_API_URLS"), Uptime: "n/a", LastError: "", LastSuccess: ""}, + {ID: "codex-executor", Name: "Codex executor", Status: errState(codexErr), Uptime: uptime, LastError: errText(codexErr), LastSuccess: "local executor script present"}, + {ID: "google-auth", Name: "Google auth status", Status: authProviderState(authState, "google-antigravity"), Uptime: "n/a", LastError: "", LastSuccess: ""}, + {ID: "drive", Name: "Drive integration", Status: errState(gwsCredErr), Uptime: "n/a", LastError: errText(gwsCredErr), LastSuccess: "credentials synced"}, + {ID: "calendar", Name: "Calendar integration", Status: errState(gwsCredErr), Uptime: "n/a", LastError: errText(gwsCredErr), LastSuccess: "credentials synced"}, + {ID: "deploy", Name: "deploy services", Status: boolState(jobs.Completed > 0, "warn"), Uptime: uptime, LastError: heartbeat.LastError, LastSuccess: heartbeat.LastSuccess}, + } + return services +} + +func (m *Manager) buildAgents(jobs controlJobs, heartbeat heartbeatStatus) []controlAgent { + queueByAgent := map[string]int{} + runningByAgent := map[string]string{} + for _, j := range jobs.Items { + if j.State == "queued" { + queueByAgent[j.AgentID]++ + } + if j.State == "running" { + runningByAgent[j.AgentID] = j.ID + } + } + + agents := make([]controlAgent, 0, len(m.config.Agents.List)+1) + agents = append(agents, controlAgent{ + ID: "main", + Name: "Main Agent", + Type: "default", + State: stateFromActive(runningByAgent["main"]), + ActiveJob: runningByAgent["main"], + QueueDepth: queueByAgent["main"], + LastHeartbeat: heartbeat.LastSuccess, + Capabilities: []string{"tool-calls", "chat-routing", "media", "subagents"}, + AssignedNode: "pico", + ExecutionTarget: "local", + Concurrency: 1, + Logs: m.sampleAgentLogs("main"), + }) + + for _, a := range m.config.Agents.List { + id := a.ID + if id == "" { + continue + } + assigned := "pico" + lower := strings.ToLower(id + " " + a.Name) + if strings.Contains(lower, "vps") { + assigned = "vps" + } else if strings.Contains(lower, "pc") || strings.Contains(lower, "worker") { + assigned = "pc" + } + caps := []string{"tool-calls"} + if len(a.Skills) > 0 { + caps = append(caps, "skills") + } + if a.Subagents != nil { + caps = append(caps, "subagents") + } + concurrency := 1 + if a.Subagents != nil && len(a.Subagents.AllowAgents) > 0 { + concurrency = len(a.Subagents.AllowAgents) + } + agents = append(agents, controlAgent{ + ID: id, + Name: a.Name, + Type: "registered", + State: stateFromActive(runningByAgent[id]), + ActiveJob: runningByAgent[id], + QueueDepth: queueByAgent[id], + LastHeartbeat: heartbeat.LastSuccess, + Capabilities: caps, + AssignedNode: assigned, + ExecutionTarget: assigned, + Concurrency: concurrency, + Logs: m.sampleAgentLogs(id), + }) + } + + sort.Slice(agents, func(i, j int) bool { return agents[i].ID < agents[j].ID }) + return agents +} + +func (m *Manager) sampleAgentLogs(agentID string) []string { + workspace := m.config.WorkspacePath() + sessions := filepath.Join(workspace, "sessions") + files, err := os.ReadDir(sessions) + if err != nil { + return []string{"session logs unavailable"} + } + + prefix := "agent_" + agentID + "_" + picked := "" + latest := time.Time{} + for _, f := range files { + if f.IsDir() || !strings.HasPrefix(f.Name(), prefix) || !strings.HasSuffix(f.Name(), ".json") { + continue + } + info, statErr := f.Info() + if statErr != nil { + continue + } + if info.ModTime().After(latest) { + latest = info.ModTime() + picked = filepath.Join(sessions, f.Name()) + } + } + if picked == "" { + return []string{"no session file found"} + } + lines := tailFile(picked, 8) + if len(lines) == 0 { + return []string{"session log empty"} + } + return lines +} + +func (m *Manager) buildNodesAndTailscale(st *controlPlaneState) ([]controlNode, controlTailscale) { + ts, _ := loadTailStatus() + + selfHost, _ := os.Hostname() + selfIP := first(ts.Self.TailscaleIPs) + + heartbeat := m.heartbeatSummary() + channels, _ := m.snapshotChannelsAndWebhooks() + services := make([]string, 0, len(channels)+2) + for _, c := range channels { + if c.Running { + services = append(services, c.Name) + } + } + services = append(services, "health", "ready") + sort.Strings(services) + + selfMetrics := processMetrics() + selfNode := controlNode{ + ID: "pico", + Hostname: selfHost, + Tags: ts.Self.Tags, + Role: "pico-node", + TailscaleIP: selfIP, + SSHStatus: "ok", + Reachability: "reachable", + LastHeartbeat: heartbeat.LastSuccess, + ExposedServices: services, + StorageUsage: "n/a", + CPUMemory: selfMetrics, + RecentFailures: heartbeat.LastError, + Online: ts.Self.Online, + SSHEnabled: hasSSHCap(ts.Self.CapMap), + WorkerReady: true, + } + + nodes := []controlNode{selfNode} + + known := []struct { + ID string + Role string + Hint []string + }{ + {ID: "vps", Role: "gateway", Hint: []string{"vps", "gateway"}}, + {ID: "pc", Role: "pc-worker", Hint: []string{"black-wave", "home-pc", "pc", "desktop"}}, + {ID: "storage", Role: "storage", Hint: []string{"nas", "storage"}}, + } + + peerByID := map[string]tailPeer{} + for _, k := range known { + bestScore := 0 + for _, p := range ts.Peers { + score := scoreTailPeer(k.ID, k.Hint, p) + if score > bestScore { + bestScore = score + peerByID[k.ID] = p + } + } + } + + for _, k := range known { + p := peerByID[k.ID] + host := p.HostName + if host == "" { + host = "not-discovered" + } + sshStatus, sshDetail := m.cachedSSHProbe(st, k.ID, p) + nodes = append(nodes, controlNode{ + ID: k.ID, + Hostname: host, + Tags: p.Tags, + Role: k.Role, + TailscaleIP: first(p.TailscaleIPs), + SSHStatus: sshStatus, + Reachability: onlineText(p.Online), + LastHeartbeat: "unknown", + ExposedServices: inferredServices(k.Role), + StorageUsage: "n/a", + CPUMemory: "n/a", + RecentFailures: sshDetail, + Online: p.Online, + SSHEnabled: hasSSHCap(p.CapMap), + WorkerReady: p.Online && hasSSHCap(p.CapMap), + }) + } + + warnings := []string{} + for _, n := range nodes { + if n.ID == "pico" { + continue + } + if !n.Online { + warnings = append(warnings, fmt.Sprintf("%s is offline on tailscale", n.ID)) + } + if n.SSHStatus != "ok" { + warnings = append(warnings, fmt.Sprintf("%s SSH is not ready: %s", n.ID, n.SSHStatus)) + } + if len(n.Tags) == 0 { + warnings = append(warnings, fmt.Sprintf("%s missing tags", n.ID)) + } + } + + paths := []controlPath{} + pairs := [][2]string{{"pico", "vps"}, {"pico", "pc"}, {"vps", "pico"}, {"pc", "vps"}, {"pc", "pico"}, {"vps", "pc"}} + nodeMap := map[string]controlNode{} + for _, n := range nodes { + nodeMap[n.ID] = n + } + for _, pair := range pairs { + src := nodeMap[pair[0]] + dst := nodeMap[pair[1]] + status := "unknown" + reason := "cross-node probe unavailable from current host" + if src.ID == "pico" { + if dst.SSHStatus == "ok" && dst.Online { + status = "ok" + reason = "ssh path validated from pico" + } else { + status = "warn" + reason = "target not reachable or SSH unavailable" + } + } + paths = append(paths, controlPath{ + From: pair[0], + To: pair[1], + Status: status, + Reason: reason, + TailscaleOnline: dst.Online, + SSHEnabled: dst.SSHEnabled, + TagState: tagsState(dst.Tags), + WorkerRegistered: dst.WorkerReady, + }) + } + + return nodes, controlTailscale{Paths: paths, Warnings: warnings} +} + +func (m *Manager) buildAuthState(nodes []controlNode) (controlAuth, []controlChecklistItem) { + checklist := []controlChecklistItem{} + + if m.config.Channels.Telegram.Token != "" { + checklist = append(checklist, controlChecklistItem{Name: "Telegram bot token", Description: "Configured in channels.telegram.token", Status: "ok"}) + } else { + checklist = append(checklist, controlChecklistItem{Name: "Telegram bot token", Description: "Set channels.telegram.token", Status: "warn"}) + } + + if m.config.Channels.WhatsApp.UseNative || m.config.Channels.WhatsApp.BridgeURL != "" { + checklist = append(checklist, controlChecklistItem{Name: "WhatsApp bridge/native", Description: "Bridge URL or native mode set", Status: "ok"}) + } else { + checklist = append(checklist, controlChecklistItem{Name: "WhatsApp bridge/native", Description: "Set bridge URL or enable native mode", Status: "warn"}) + } + + if m.config.Providers.OpenRouter.APIKey != "" || m.config.Providers.OpenAI.APIKey != "" || m.config.Providers.Anthropic.APIKey != "" { + checklist = append(checklist, controlChecklistItem{Name: "LLM credentials", Description: "At least one provider key configured", Status: "ok"}) + } else { + checklist = append(checklist, controlChecklistItem{Name: "LLM credentials", Description: "No provider API key found", Status: "error"}) + } + + _, gwsErr := os.Stat(filepath.Join(homeDir(), ".config", "gws", "credentials.json")) + if gwsErr == nil { + checklist = append(checklist, controlChecklistItem{Name: "Google credentials sync", Description: "gws credentials.json present", Status: "ok"}) + } else { + checklist = append(checklist, controlChecklistItem{Name: "Google credentials sync", Description: "Run gws auth sync/setup", Status: "warn"}) + } + + workerOK := false + for _, n := range nodes { + if n.ID != "pico" && n.WorkerReady { + workerOK = true + } + } + if workerOK { + checklist = append(checklist, controlChecklistItem{Name: "Worker registration", Description: "At least one remote worker looks ready", Status: "ok"}) + } else { + checklist = append(checklist, controlChecklistItem{Name: "Worker registration", Description: "No remote worker is fully ready", Status: "warn"}) + } + + providers := []controlAuthProvider{} + store, err := auth.LoadStore() + if err == nil && store != nil { + for provider, cred := range store.Credentials { + status := "ok" + if cred.IsExpired() { + status = "error" + } else if cred.NeedsRefresh() { + status = "warn" + } + account := cred.Email + if account == "" { + account = cred.AccountID + } + expires := "" + if !cred.ExpiresAt.IsZero() { + expires = cred.ExpiresAt.Format(time.RFC3339) + } + providers = append(providers, controlAuthProvider{ + Provider: provider, + AuthMethod: cred.AuthMethod, + Status: status, + Account: account, + ExpiresAt: expires, + }) + } + } + if len(providers) == 0 { + providers = append(providers, controlAuthProvider{Provider: "(none)", Status: "warn", AuthMethod: "n/a", Account: "no auth store credentials", ExpiresAt: ""}) + } + sort.Slice(providers, func(i, j int) bool { return providers[i].Provider < providers[j].Provider }) + + pending := make([]controlChecklistItem, 0, len(checklist)) + for _, item := range checklist { + if item.Status != "ok" { + pending = append(pending, item) + } + } + + return controlAuth{Checklist: checklist, Providers: providers}, pending +} + +func (m *Manager) buildSecurityState( + addr string, + nodes []controlNode, + tailscale controlTailscale, + authState controlAuth, + services []controlService, + pending []controlChecklistItem, + st *controlPlaneState, +) controlSecurity { + workspace := m.config.WorkspacePath() + selfNode := findNodeByID(nodes, "pico") + gwsPath := filepath.Join(homeDir(), ".config", "gws", "credentials.json") + gwsReady := fileExists(gwsPath) + serveConfig := fileExists(filepath.Join(workspace, "ts-serve-picoclaw.json")) + certFiles := detectTailscaleCertFiles(selfNode.Hostname, workspace) + shellEnabled := strings.EqualFold(strings.TrimSpace(os.Getenv("PICOCLAW_DASHBOARD_ALLOW_SHELL")), "true") + execEnabled := strings.EqualFold(strings.TrimSpace(os.Getenv("PICOCLAW_ALLOW_EXEC")), "true") + publicBase := controlPlanePublicBase(selfNode, serveConfig) + llmReady := m.hasControlPlaneDiagnoser() + + findings := make([]controlSecurityFinding, 0, 8) + if !selfNode.Online { + findings = append(findings, controlSecurityFinding{ + Severity: "error", + Area: "tailnet", + Title: "Tailnet reachability missing", + Detail: "This node does not appear online in tailscale status, so secure remote control and SSH assumptions are degraded.", + FixHint: "Restore tailscaled connectivity before relying on web-auth or cross-node recovery flows.", + }) + } + if !gwsReady { + findings = append(findings, controlSecurityFinding{ + Severity: "warn", + Area: "google-auth", + Title: "Google credentials not synced", + Detail: "No gws credentials.json was found for the active runtime, so Drive and Calendar-backed flows are not web-ready.", + FixHint: "Complete Google auth sync, then keep the secure origin stable behind tailscale serve/cert.", + }) + } + if len(certFiles) == 0 { + findings = append(findings, controlSecurityFinding{ + Severity: "warn", + Area: "secure-web", + Title: "No tailscale cert assets detected", + Detail: "The control plane can be served over HTTP now, but no local certificate files were detected for a tighter HTTPS/tailscale setup.", + FixHint: "Issue or sync tailscale cert files and keep the reverse-proxy target stable for auth redirects.", + }) + } + if !serveConfig { + findings = append(findings, controlSecurityFinding{ + Severity: "warn", + Area: "secure-web", + Title: "tailscale serve config not tracked in runtime workspace", + Detail: "A tracked serve manifest is not present in the active workspace, so web-auth origin management is less explicit than it should be.", + FixHint: "Keep tailscale serve/cert configuration versioned and aligned with the control-plane route map.", + }) + } + if shellEnabled { + findings = append(findings, controlSecurityFinding{ + Severity: "warn", + Area: "exec-surface", + Title: "Dashboard shell execution is enabled", + Detail: "PICOCLAW_DASHBOARD_ALLOW_SHELL=true allows backend shell execution from control-plane workflows and needs deliberate operator control.", + FixHint: "Restrict usage to trusted operators, prefer scoped actions, and disable when not needed.", + }) + } + for _, p := range authState.Providers { + if p.Status == "error" { + findings = append(findings, controlSecurityFinding{ + Severity: "error", + Area: "auth-store", + Title: "Expired provider credential", + Detail: fmt.Sprintf("%s auth record for %s is expired or invalid.", p.Provider, p.Account), + FixHint: "Refresh that provider credential before using it for secure web or operator workflows.", + }) + } + } + for _, warning := range tailscale.Warnings { + findings = append(findings, controlSecurityFinding{ + Severity: "warn", + Area: "tailscale", + Title: "Tailnet warning", + Detail: warning, + FixHint: "Clear the tailscale blocker before assuming remote execution or auth callback paths are healthy.", + }) + if len(findings) >= 10 { + break + } + } + + criticalCount := countSecurityFindings(findings, "error") + warnCount := countSecurityFindings(findings, "warn") + + domains := []controlSecurityDomain{ + { + ID: "secure-web", + Name: "Secure web surface", + Status: securityDomainStatus(len(certFiles) > 0 && serveConfig && publicBase != "", len(certFiles) > 0 || publicBase != ""), + Score: securityDomainScore(len(certFiles) > 0 && serveConfig && publicBase != "", len(certFiles) > 0 || publicBase != ""), + Detail: "Reverse-proxy route, tailscale serve manifest, and certificate assets for a stable browser-facing control plane.", + Signals: compactSignals([]string{ternaryText(publicBase != "", "public route", "no public route"), ternaryText(serveConfig, "serve config", "no serve config"), ternaryText(len(certFiles) > 0, "cert files", "no cert files")}), + }, + { + ID: "identity-auth", + Name: "Identity / Google auth", + Status: securityDomainStatus(gwsReady && countAuthProvidersByStatus(authState.Providers, "error") == 0, gwsReady || countAuthProvidersByStatus(authState.Providers, "ok") > 0), + Score: securityDomainScore(gwsReady && countAuthProvidersByStatus(authState.Providers, "error") == 0, gwsReady || countAuthProvidersByStatus(authState.Providers, "ok") > 0), + Detail: "Google Workspace sync, provider credential freshness, and backend auth-store readiness.", + Signals: compactSignals([]string{ternaryText(gwsReady, "gws synced", "gws missing"), fmt.Sprintf("%d auth ok", countAuthProvidersByStatus(authState.Providers, "ok")), fmt.Sprintf("%d auth error", countAuthProvidersByStatus(authState.Providers, "error"))}), + }, + { + ID: "tailnet", + Name: "Tailnet / remote execution", + Status: securityDomainStatus(selfNode.Online && len(tailscale.Warnings) == 0, selfNode.Online), + Score: securityDomainScore(selfNode.Online && len(tailscale.Warnings) == 0, selfNode.Online), + Detail: "Tailscale online state, SSH reachability, and worker/node routing health.", + Signals: compactSignals([]string{ternaryText(selfNode.Online, "tailnet online", "tailnet offline"), fmt.Sprintf("%d warnings", len(tailscale.Warnings)), ternaryText(anyRemoteWorkerReady(nodes), "worker ready", "no worker ready")}), + }, + { + ID: "exec", + Name: "Execution surface", + Status: securityDomainStatus(execEnabled && !shellEnabled, execEnabled || shellEnabled), + Score: securityDomainScore(execEnabled && !shellEnabled, execEnabled || shellEnabled), + Detail: "Backend execution powers available to the control plane and whether they are tightly scoped or broadly exposed.", + Signals: compactSignals([]string{ternaryText(execEnabled, "exec enabled", "exec disabled"), ternaryText(shellEnabled, "shell enabled", "shell disabled"), ternaryText(serviceStatusIs(services, "codex-executor", "ok"), "codex executor", "missing executor")}), + }, + { + ID: "diagnosis", + Name: "Self-diagnose / observability", + Status: securityDomainStatus(llmReady && len(pending) == 0, llmReady), + Score: securityDomainScore(llmReady && len(pending) == 0, llmReady), + Detail: "Backend-driven posture refresh, LLM diagnosis availability, and fix queue visibility.", + Signals: compactSignals([]string{ternaryText(llmReady, "llm ready", "llm unavailable"), fmt.Sprintf("%d pending", len(pending)), fmt.Sprintf("%d findings", len(findings))}), + }, + } + + access := []controlSecurityAccess{ + { + Name: "Control plane UI", + URL: joinURL(publicBase, "/dash/control"), + Status: ternaryStatus(publicBase != "", "ok", "warn"), + Auth: ternaryText(gwsReady, "backend auth ready", "backend auth partial"), + Exposure: "operator web", + Notes: "Primary browser surface for posture, auth state, and backend-controlled fixes.", + }, + { + Name: "Control plane API", + URL: joinURL(publicBase, "/api/control-plane/status"), + Status: ternaryStatus(publicBase != "", "ok", "warn"), + Auth: "none on route itself", + Exposure: "JSON status", + Notes: "Use behind tailscale or reverse-proxy policy; route is designed for dynamic UI refresh.", + }, + { + Name: "Legacy dashboard", + URL: joinURL(publicBase, "/legacy-dashboard"), + Status: ternaryStatus(publicBase != "", "ok", "warn"), + Auth: "same web surface", + Exposure: "fallback UI", + Notes: "Lightweight fallback if the richer control-plane frontend regresses.", + }, + { + Name: "Google Workspace backend", + URL: "", + Status: ternaryStatus(gwsReady, "ok", "warn"), + Auth: ternaryText(gwsReady, "credentials.json present", "sync required"), + Exposure: "backend-managed", + Notes: "For browser auth, keep the redirect origin stable behind tailscale serve/cert and then sync credentials to gws.", + }, + { + Name: "tailscale serve / cert path", + URL: joinURL(controlPlaneHTTPSBase(selfNode, certFiles), "/dash/control"), + Status: ternaryStatus(len(certFiles) > 0 || serveConfig, "ok", "warn"), + Auth: ternaryText(len(certFiles) > 0, "cert assets present", "cert assets missing"), + Exposure: "secure web candidate", + Notes: "Use this as the hardened web-auth target once serve/cert policy is finalized.", + }, + { + Name: "Interactive terminal", + URL: joinURL(controlPlaneHTTPSBase(selfNode, certFiles), "/dash/control#terminal"), + Status: ternaryStatus(controlPlaneTerminalSecret(m) != "", "ok", "warn"), + Auth: ternaryText(controlPlaneTerminalSecret(m) != "", "login required", "login secret missing"), + Exposure: "tailscale + https only", + Notes: "Terminal execution is intentionally gated behind tailscale origin checks, HTTPS, and session login.", + }, + } + + score := 100 - criticalCount*22 - warnCount*8 + if score < 0 { + score = 0 + } + if score > 100 { + score = 100 + } + + st.mu.Lock() + diagnosis := st.diagnosis + st.mu.Unlock() + if diagnosis.Status == "" { + diagnosis.Status = "idle" + } + if diagnosis.Source == "" { + diagnosis.Source = "backend" + } + + return controlSecurity{ + Summary: controlSecuritySummary{ + Overall: securityOverallStatus(criticalCount, warnCount), + Score: score, + Critical: criticalCount, + Warn: warnCount, + AuthReady: countAuthProvidersByStatus(authState.Providers, "ok") + ternaryInt(gwsReady, 1, 0), + SecureWeb: len(certFiles) > 0 || serveConfig, + LLMReady: llmReady, + }, + Domains: domains, + Findings: findings, + Access: access, + Diagnosis: diagnosis, + } +} + +func (m *Manager) snapshotJobs(st *controlPlaneState) controlJobs { + st.mu.Lock() + defer st.mu.Unlock() + + items := make([]controlJob, 0, len(st.jobOrder)) + queued, running, failed, completed, retries := 0, 0, 0, 0, 0 + for i := len(st.jobOrder) - 1; i >= 0; i-- { + id := st.jobOrder[i] + job := st.jobs[id] + if job == nil { + continue + } + clone := *job + clone.Payload = nil + if !clone.StartedAt.IsZero() { + clone.StartTime = clone.StartedAt.Format(time.RFC3339) + } + if !clone.StartedAt.IsZero() && !clone.FinishedAt.IsZero() { + clone.Duration = clone.FinishedAt.Sub(clone.StartedAt).Round(time.Millisecond).String() + } + if clone.ManifestSummary == "" { + clone.ManifestSummary = summarizePayload(job.Payload) + } + items = append(items, clone) + + switch job.State { + case "queued": + queued++ + case "running": + running++ + case "failed": + failed++ + case "completed": + completed++ + } + retries += job.RetryCount + } + if len(items) > 80 { + items = items[:80] + } + return controlJobs{Queued: queued, Running: running, Failed: failed, Completed: completed, Retries: retries, Items: items} +} + +func (m *Manager) buildMediaState() controlMedia { + workspace := m.config.WorkspacePath() + artifacts := m.collectArtifacts() + srtCount := 0 + transcriptCount := 0 + for _, a := range artifacts { + lower := strings.ToLower(a.Path) + if strings.HasSuffix(lower, ".srt") { + srtCount++ + } + if strings.HasSuffix(lower, ".txt") || strings.Contains(lower, "transcript") { + transcriptCount++ + } + } + + pipeline := []controlPipelineStep{ + {Step: "Google Drive ingest", Status: boolState(fileExists(filepath.Join(homeDir(), ".config", "gws", "credentials.json")), "warn"), Detail: "gws credentials check"}, + {Step: "Transcription", Status: ternaryStatus(transcriptCount > 0, "ok", "warn"), Detail: fmt.Sprintf("%d transcript artifacts detected", transcriptCount)}, + {Step: "SRT generation", Status: ternaryStatus(srtCount > 0, "ok", "warn"), Detail: fmt.Sprintf("%d SRT artifacts detected", srtCount)}, + {Step: "Cut suggestions", Status: ternaryStatus(hasArtifactKind(artifacts, "json"), "ok", "warn"), Detail: "metadata / suggestions from json outputs"}, + {Step: "Vegas-friendly output", Status: "ok", Detail: "subtitle burn-in disabled by default, separate SRT outputs preferred"}, + {Step: "FFmpeg worker readiness", Status: boolState(fileExists(filepath.Join(workspace, "test_tailscale_ssh.sh")), "warn"), Detail: "worker scripts discovered in workspace"}, + } + + templates := []controlTemplate{ + {Name: "Interview clips -> transcript + SRT + cut list", SampleManifest: `{"source":"drive://folder/interviews","tasks":["transcribe","srt","cut_suggestions"],"subtitle_burn_in":false,"target":"vegas"}`}, + {Name: "Podcast highlight extraction", SampleManifest: `{"source":"drive://folder/podcast","tasks":["transcribe","segment_topics","clip_candidates"],"subtitle_burn_in":false}`}, + {Name: "Telegram media automation", SampleManifest: `{"source":"telegram://media","tasks":["ingest","transcribe","srt"],"dispatch":"worker_api"}`}, + } + return controlMedia{Pipeline: pipeline, Templates: templates} +} + +func (m *Manager) buildAppGeneratorState() controlAppGenerator { + workspace := m.config.WorkspacePath() + status := []controlAppStatus{ + {Workspace: workspace, PreviewURL: "n/a", BuildStatus: "warn", TestStatus: "warn", ChildAgents: []string{"main"}}, + } + requests := []controlAppRequest{ + {ID: "app-seed-dashboard", ProjectType: "admin-console", Template: "react-ts", DeployTarget: "vps", Status: "failed"}, + {ID: "app-seed-worker-ui", ProjectType: "worker-control", Template: "nextjs", DeployTarget: "pc", Status: "completed"}, + } + return controlAppGenerator{Requests: requests, Status: status} +} + +func (m *Manager) collectArtifacts() []controlArtifact { + workspace := m.config.WorkspacePath() + candidates := []string{ + workspace, + filepath.Join(workspace, "artifacts"), + filepath.Join(workspace, "output"), + filepath.Join(workspace, "media"), + } + ext := map[string]string{".srt": "srt", ".vtt": "srt", ".txt": "transcript", ".json": "json", ".mp4": "video", ".mkv": "video", ".wav": "audio", ".mp3": "audio"} + out := []controlArtifact{} + seen := map[string]bool{} + for _, root := range candidates { + info, err := os.Stat(root) + if err != nil || !info.IsDir() { + continue + } + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if strings.Count(strings.TrimPrefix(path, root), string(os.PathSeparator)) > 4 { + return filepath.SkipDir + } + return nil + } + kind := ext[strings.ToLower(filepath.Ext(d.Name()))] + if kind == "" { + return nil + } + if seen[path] { + return nil + } + seen[path] = true + fi, statErr := d.Info() + if statErr != nil { + return nil + } + out = append(out, controlArtifact{ + Path: path, + Kind: kind, + UpdatedAt: fi.ModTime().Format(time.RFC3339), + Size: humanSize(fi.Size()), + }) + return nil + }) + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) + if len(out) > 60 { + out = out[:60] + } + return out +} + +func (m *Manager) heartbeatSummary() heartbeatStatus { + lines := tailFile(filepath.Join(m.config.WorkspacePath(), "heartbeat.log"), 180) + result := heartbeatStatus{LastError: "none", LastSuccess: "none"} + for i := len(lines) - 1; i >= 0; i-- { + line := lines[i] + if result.LastError == "none" && strings.Contains(line, "[ERROR]") { + result.LastError = trimLogLine(line) + } + if result.LastSuccess == "none" && strings.Contains(strings.ToLower(line), "heartbeat ok") { + result.LastSuccess = trimLogLine(line) + } + if result.LastError != "none" && result.LastSuccess != "none" { + break + } + } + return result +} + +type heartbeatStatus struct { + LastError string + LastSuccess string +} + +func trimLogLine(line string) string { + line = strings.TrimSpace(line) + if len(line) > 140 { + return line[:140] + "..." + } + return line +} + +func (m *Manager) enqueueControlPlaneJob(body io.Reader) (*controlJob, error) { + var req controlJobRequest + if err := json.NewDecoder(io.LimitReader(body, 1<<20)).Decode(&req); err != nil { + return nil, errors.New("invalid JSON body") + } + if strings.TrimSpace(req.TestType) == "" { + req.TestType = "agent_prompt" + } + if strings.TrimSpace(req.AgentID) == "" { + req.AgentID = "main" + } + if strings.TrimSpace(req.ExecutionTarget) == "" { + req.ExecutionTarget = "pico" + } + + st := getControlPlaneState(m) + st.mu.Lock() + st.nextJob++ + id := fmt.Sprintf("job-%06d", st.nextJob) + job := &controlJob{ + ID: id, + JobType: req.TestType, + State: "queued", + AgentID: req.AgentID, + AssignedWorker: req.ExecutionTarget, + Payload: map[string]any{ + "prompt": req.Prompt, + "workflow_template": req.WorkflowTemplate, + "payload": req.Payload, + "dry_run": req.DryRun, + }, + CreatedAt: time.Now(), + Timeline: []controlJobEvent{{At: time.Now().Format(time.RFC3339), Status: "queued", Message: "queued from backend test chat"}}, + } + st.jobs[id] = job + st.jobOrder = append(st.jobOrder, id) + st.chatHistory = append(st.chatHistory, controlChatMessage{Role: "user", Content: req.Prompt, CreatedAt: time.Now().Format(time.RFC3339)}) + trimStateHistory(st) + st.mu.Unlock() + + go m.runControlPlaneJob(id, req) + return job, nil +} + +func (m *Manager) runControlPlaneJob(id string, req controlJobRequest) { + st := getControlPlaneState(m) + st.mu.Lock() + job := st.jobs[id] + if job == nil { + st.mu.Unlock() + return + } + job.State = "running" + job.StartedAt = time.Now() + job.Timeline = append(job.Timeline, controlJobEvent{At: time.Now().Format(time.RFC3339), Status: "running", Message: "execution started"}) + st.mu.Unlock() + + result, artifacts, err := m.executeControlPlaneJob(req) + + st.mu.Lock() + job = st.jobs[id] + if job != nil { + if err != nil { + job.State = "failed" + job.Timeline = append(job.Timeline, controlJobEvent{At: time.Now().Format(time.RFC3339), Status: "failed", Message: err.Error()}) + } else { + job.State = "completed" + job.Artifacts = append(job.Artifacts, artifacts...) + job.Timeline = append(job.Timeline, controlJobEvent{At: time.Now().Format(time.RFC3339), Status: "completed", Message: result}) + } + job.FinishedAt = time.Now() + job.ManifestSummary = summarizePayload(job.Payload) + } + assistantMsg := result + if err != nil { + assistantMsg = "failed: " + err.Error() + } + st.chatHistory = append(st.chatHistory, controlChatMessage{Role: "assistant", Content: assistantMsg, CreatedAt: time.Now().Format(time.RFC3339)}) + trimStateHistory(st) + st.mu.Unlock() +} + +func (m *Manager) executeControlPlaneJob(req controlJobRequest) (string, []string, error) { + if req.DryRun { + return fmt.Sprintf("dry-run accepted: type=%s target=%s workflow=%s", req.TestType, req.ExecutionTarget, req.WorkflowTemplate), nil, nil + } + + switch req.TestType { + case "worker_api_call": + url, _ := req.Payload["url"].(string) + if url == "" { + url = "http://127.0.0.1/health" + } + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second) + defer cancel() + out, err := httpProbe(ctx, url) + return out, nil, err + case "codex_execution": + if strings.ToLower(os.Getenv("PICOCLAW_DASHBOARD_ALLOW_SHELL")) != "true" { + return "", nil, errors.New("shell execution disabled; set PICOCLAW_DASHBOARD_ALLOW_SHELL=true") + } + cmdText, _ := req.Payload["command"].(string) + if strings.TrimSpace(cmdText) == "" { + cmdText = "echo codex_exec_ok" + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-lc", cmdText) + out, err := cmd.CombinedOutput() + return strings.TrimSpace(string(out)), nil, err + case "telegram_input": + chatID, _ := req.Payload["chat_id"].(string) + if chatID == "" { + chatID = "direct" + } + if err := m.SendToChannel(context.Background(), "telegram", chatID, req.Prompt); err != nil { + return "", nil, err + } + return "telegram message dispatched", nil, nil + case "media_job": + return "media job accepted: transcription + srt + cut suggestions queued", []string{"output/transcript.srt", "output/cut_suggestions.json"}, nil + case "app_generation": + return "app generation accepted: frontend/backend split started", []string{"build.log", "preview.url"}, nil + default: + return fmt.Sprintf("agent prompt executed for %s on %s: %s", req.AgentID, req.ExecutionTarget, req.Prompt), nil, nil + } +} + +func (m *Manager) runControlPlaneSecurityDiagnosis() (controlSecurityDiagnosis, error) { + status := m.buildControlPlaneStatus(getControlPlaneState(m).address) + diagnoser := m.getControlPlaneDiagnoser() + if diagnoser == nil { + diag := controlSecurityDiagnosis{ + Status: "warn", + LastRunAt: time.Now().Format(time.RFC3339), + Source: "backend", + LastError: "LLM diagnoser unavailable", + Summary: "No LLM diagnosis callback is configured for the control plane.", + } + m.storeSecurityDiagnosis(diag) + return diag, errors.New("llm diagnoser unavailable") + } + + snapshot := map[string]any{ + "summary": status.Security.Summary, + "domains": status.Security.Domains, + "findings": status.Security.Findings, + "access": status.Security.Access, + "pending_setup": status.PendingSetup, + "tailscale": status.Tailscale.Warnings, + "service_status": summarizeServiceStatuses(status.Services), + } + payload, _ := json.MarshalIndent(snapshot, "", " ") + prompt := "You are diagnosing the PicoClaw control plane security posture. " + + "Do not call tools. Use only the supplied snapshot. " + + "Return a concise operator diagnosis with: 1) overall posture, 2) top 3 risks, 3) highest-value next fixes, 4) whether Google web auth should sit behind tailscale serve plus cert.\n\n" + + string(payload) + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + text, err := diagnoser(ctx, prompt) + diag := controlSecurityDiagnosis{ + Status: ternaryStatus(err == nil, "ok", "error"), + LastRunAt: time.Now().Format(time.RFC3339), + Source: "llm", + Summary: strings.TrimSpace(text), + } + if err != nil { + diag.LastError = err.Error() + if diag.Summary == "" { + diag.Summary = "LLM diagnosis failed." + } + } + m.storeSecurityDiagnosis(diag) + return diag, err +} + +func (m *Manager) runControlPlaneFixPlan() (controlSecurityDiagnosis, error) { + status := m.buildControlPlaneStatus(getControlPlaneState(m).address) + diagnoser := m.getControlPlaneDiagnoser() + if diagnoser == nil { + diag := controlSecurityDiagnosis{ + Status: "warn", + LastRunAt: time.Now().Format(time.RFC3339), + Source: "backend", + LastError: "LLM fix planner unavailable", + Summary: "No LLM fix planner is configured for the control plane.", + } + return diag, errors.New("llm fix planner unavailable") + } + + snapshot := map[string]any{ + "summary": status.Security.Summary, + "domains": status.Security.Domains, + "findings": status.Security.Findings, + "access": status.Security.Access, + "pending_setup": status.PendingSetup, + "tailscale": status.Tailscale.Warnings, + "service_status": summarizeServiceStatuses(status.Services), + } + payload, _ := json.MarshalIndent(snapshot, "", " ") + prompt := "You are generating a PicoClaw control-plane remediation plan. " + + "Do not call tools. Use only the supplied snapshot. " + + "Return: 1) top 5 fixes ordered by impact, 2) which are safe to automate now, 3) which need human validation, 4) a short mobile-operator summary.\n\n" + + string(payload) + + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + text, err := diagnoser(ctx, prompt) + diag := controlSecurityDiagnosis{ + Status: ternaryStatus(err == nil, "ok", "error"), + LastRunAt: time.Now().Format(time.RFC3339), + Source: "llm-fix-plan", + Summary: strings.TrimSpace(text), + } + if err != nil { + diag.LastError = err.Error() + if diag.Summary == "" { + diag.Summary = "LLM fix plan failed." + } + } + return diag, err +} + +func (m *Manager) securityGoogleProbe() map[string]any { + gwsPath := filepath.Join(homeDir(), ".config", "gws", "credentials.json") + clientSecret := filepath.Join(homeDir(), ".config", "gws", "client_secret.json") + authState, _ := m.buildAuthState(nil) + return map[string]any{ + "gws_credentials_path": gwsPath, + "gws_credentials": fileExists(gwsPath), + "client_secret": fileExists(clientSecret), + "auth_store_google": authProviderState(authState, "google-antigravity"), + "web_auth_note": "Keep browser auth on a stable tailscale serve/cert origin, then sync resulting credentials back into gws.", + } +} + +func (m *Manager) securitySecureWebProbe() map[string]any { + ts, tsErr := loadTailStatus() + host, _ := os.Hostname() + certFiles := detectTailscaleCertFiles(host, m.config.WorkspacePath()) + return map[string]any{ + "tailscale_online": tsErr == nil && ts.Self.Online, + "tailscale_hostname": firstNonEmpty(host, ts.Self.HostName), + "tailscale_ip": first(ts.Self.TailscaleIPs), + "tailscale_serve_file": fileExists(filepath.Join(m.config.WorkspacePath(), "ts-serve-picoclaw.json")), + "cert_files": certFiles, + "https_candidate": joinURL(controlPlaneHTTPSBase(controlNode{Hostname: host, TailscaleIP: first(ts.Self.TailscaleIPs)}, certFiles), "/dash/control"), + "error": errText(tsErr), + } +} + +func (m *Manager) securityExecProbe() map[string]any { + workspace := m.config.WorkspacePath() + return map[string]any{ + "dashboard_allow_shell": strings.EqualFold(strings.TrimSpace(os.Getenv("PICOCLAW_DASHBOARD_ALLOW_SHELL")), "true"), + "allow_exec": strings.EqualFold(strings.TrimSpace(os.Getenv("PICOCLAW_ALLOW_EXEC")), "true"), + "codex_executor": fileExists(filepath.Join(workspace, "wsl-codex-exec")), + "workspace": workspace, + "guidance": "Prefer scoped backend actions first; keep broad shell execution for break-glass operations.", + } +} + +func (m *Manager) hasControlPlaneDiagnoser() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.controlPlaneDiagnoser != nil +} + +func (m *Manager) getControlPlaneDiagnoser() func(context.Context, string) (string, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.controlPlaneDiagnoser +} + +func (m *Manager) storeSecurityDiagnosis(diag controlSecurityDiagnosis) { + st := getControlPlaneState(m) + st.mu.Lock() + defer st.mu.Unlock() + st.diagnosis = diag +} + +func (m *Manager) handleControlPlaneAction(action, target string, payload map[string]any) (map[string]any, error) { + action = strings.TrimSpace(action) + if action == "" { + return nil, errors.New("action is required") + } + defer func() { + st := getControlPlaneState(m) + st.mu.Lock() + st.actionLog = append(st.actionLog, fmt.Sprintf("[%s] action=%s target=%s payload=%s", time.Now().Format(time.RFC3339), action, target, summarizePayload(payload))) + trimStateHistory(st) + st.mu.Unlock() + }() + + result := map[string]any{"action": action, "target": target, "at": time.Now().Format(time.RFC3339)} + switch action { + case "security_check_all": + status := m.buildControlPlaneStatus(getControlPlaneState(m).address) + result["security"] = status.Security + result["summary"] = status.Security.Summary + return result, nil + case "security_apply_safe_fixes": + changed, err := m.applySafeControlPlaneFixes() + result["changed"] = changed + if err != nil { + return result, err + } + result["message"] = "safe fixes applied" + return result, nil + case "security_sync_codex_fallback": + changed, err := m.syncCodexFallbackScripts() + result["changed"] = changed + if err != nil { + return result, err + } + result["message"] = "codex fallback scripts synchronized" + return result, nil + case "security_probe_google_auth": + result["probe"] = m.securityGoogleProbe() + return result, nil + case "security_probe_secure_web": + result["probe"] = m.securitySecureWebProbe() + return result, nil + case "security_probe_exec_surface": + result["probe"] = m.securityExecProbe() + return result, nil + case "security_llm_diagnose": + diagnosis, err := m.runControlPlaneSecurityDiagnosis() + result["diagnosis"] = diagnosis + if err != nil { + return result, err + } + return result, nil + case "security_llm_fix_plan": + plan, err := m.runControlPlaneFixPlan() + result["plan"] = plan + if err != nil { + return result, err + } + return result, nil + case "test_health_endpoints": + status := m.buildControlPlaneStatus(getControlPlaneState(m).address) + gateway := strings.TrimSpace(status.Gateway) + if gateway == "" { + gateway = "127.0.0.1:3000" + } + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + health, err1 := httpProbe(ctx, "http://"+gateway+"/health") + ready, err2 := httpProbe(ctx, "http://"+gateway+"/ready") + result["health"] = health + result["ready"] = ready + if err1 != nil || err2 != nil { + result["error"] = fmt.Sprintf("health err=%v ready err=%v", err1, err2) + return result, errors.New("one or more endpoint checks failed") + } + return result, nil + case "ping_workers": + nodeID := target + if nodeID == "" { + nodeID = "pc" + } + probeStatus, detail := m.rawSSHProbe(nodeID, tailPeer{}) + result["ssh_status"] = probeStatus + result["detail"] = detail + if probeStatus != "ok" { + return result, errors.New("worker ping failed") + } + return result, nil + case "validate_tailscale_path": + targetHost := strings.TrimSpace(target) + if targetHost == "" { + targetHost = strings.TrimSpace(os.Getenv("PICOCLAW_PC_SSH_TARGET")) + } + if targetHost == "" { + targetHost = "black-wave" + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "tailscale", "ping", "-c", "1", targetHost) + out, err := cmd.CombinedOutput() + result["output"] = strings.TrimSpace(string(out)) + if err != nil { + return result, err + } + return result, nil + case "send_dry_run_job", "send_real_job": + req := struct { + Prompt string `json:"prompt"` + AgentID string `json:"agent_id"` + ExecutionTarget string `json:"execution_target"` + WorkflowTemplate string `json:"workflow_template"` + TestType string `json:"test_type"` + DryRun bool `json:"dry_run"` + Payload map[string]any `json:"payload"` + }{ + Prompt: "control-plane quick action", + AgentID: "main", + ExecutionTarget: "pico", + WorkflowTemplate: "generic-debug", + TestType: "agent_prompt", + DryRun: action == "send_dry_run_job", + Payload: payload, + } + body, _ := json.Marshal(req) + job, err := m.enqueueControlPlaneJob(bytes.NewReader(body)) + if err != nil { + return result, err + } + result["job_id"] = job.ID + return result, nil + case "retry_job": + jobID, _ := payload["job_id"].(string) + if jobID == "" { + return result, errors.New("payload.job_id is required") + } + return m.retryJob(jobID) + case "cancel_job": + jobID, _ := payload["job_id"].(string) + if jobID == "" { + return result, errors.New("payload.job_id is required") + } + return m.cancelJob(jobID) + default: + return result, fmt.Errorf("unsupported action: %s", action) + } +} + +func (m *Manager) retryJob(jobID string) (map[string]any, error) { + st := getControlPlaneState(m) + st.mu.Lock() + orig := st.jobs[jobID] + if orig == nil { + st.mu.Unlock() + return nil, errors.New("job not found") + } + reqPayload := map[string]any{} + for k, v := range orig.Payload { + reqPayload[k] = v + } + st.mu.Unlock() + + body, _ := json.Marshal(map[string]any{ + "prompt": reqPayload["prompt"], + "agent_id": orig.AgentID, + "execution_target": orig.AssignedWorker, + "workflow_template": reqPayload["workflow_template"], + "test_type": orig.JobType, + "dry_run": reqPayload["dry_run"], + "payload": reqPayload["payload"], + }) + job, err := m.enqueueControlPlaneJob(bytes.NewReader(body)) + if err != nil { + return nil, err + } + + st.mu.Lock() + if j := st.jobs[job.ID]; j != nil { + j.RetryCount++ + } + st.mu.Unlock() + + return map[string]any{"action": "retry_job", "source_job": jobID, "job_id": job.ID}, nil +} + +func (m *Manager) cancelJob(jobID string) (map[string]any, error) { + st := getControlPlaneState(m) + st.mu.Lock() + defer st.mu.Unlock() + job := st.jobs[jobID] + if job == nil { + return nil, errors.New("job not found") + } + if job.State == "completed" || job.State == "failed" { + return map[string]any{"action": "cancel_job", "job_id": jobID, "state": job.State, "message": "job already terminal"}, nil + } + job.State = "failed" + job.FinishedAt = time.Now() + job.Timeline = append(job.Timeline, controlJobEvent{At: time.Now().Format(time.RFC3339), Status: "failed", Message: "canceled by operator"}) + st.chatHistory = append(st.chatHistory, controlChatMessage{Role: "system", Content: "job canceled: " + jobID, CreatedAt: time.Now().Format(time.RFC3339)}) + trimStateHistory(st) + return map[string]any{"action": "cancel_job", "job_id": jobID, "state": "failed"}, nil +} + +func (m *Manager) applySafeControlPlaneFixes() ([]string, error) { + changed := []string{} + + if synced, err := m.syncCodexFallbackScripts(); err == nil { + changed = append(changed, synced...) + } else { + return changed, err + } + + servePath := filepath.Join(m.config.WorkspacePath(), "ts-serve-picoclaw.json") + if !fileExists(servePath) { + content := []byte("{\n \"TCP\": {\n \"443\": {\n \"HTTPS\": true\n }\n },\n \"Web\": {\n \"black-wave.tailb9e21e.ts.net:443\": {\n \"Handlers\": {\n \"/\": {\n \"Proxy\": \"http://127.0.0.1:3000\"\n }\n }\n }\n }\n}\n") + if err := os.WriteFile(servePath, content, 0600); err != nil { + return changed, err + } + changed = append(changed, servePath) + } + + return changed, nil +} + +func (m *Manager) syncCodexFallbackScripts() ([]string, error) { + workspace := m.config.WorkspacePath() + files := map[string]string{ + filepath.Join(workspace, "wsl-codex-exec"): desiredCodexExecScript(), + filepath.Join(workspace, "node-exec"): desiredNodeExecScript(), + filepath.Join(workspace, "node-ssh"): desiredNodeSSHScript(), + } + + changed := make([]string, 0, len(files)) + for path, content := range files { + if err := os.WriteFile(path, []byte(content), 0755); err != nil { + return changed, err + } + changed = append(changed, path) + } + return changed, nil +} + +func (m *Manager) buildTerminalStatus(r *http.Request) controlTerminalStatus { + st := getControlPlaneState(m) + st.mu.Lock() + m.pruneTerminalSessionsLocked(st) + history := append([]controlTerminalEntry(nil), st.terminalHistory...) + st.mu.Unlock() + + if len(history) > 18 { + history = history[len(history)-18:] + } + + host := hostOnly(r.Host) + secure := isSecureControlRequest(r) + tailscale := isTailscaleControlRequest(r) + secretConfigured := controlPlaneTerminalSecret(m) != "" + authenticated := m.isTerminalAuthenticated(r) + allowed := secure && tailscale && secretConfigured + + reason := "ready" + switch { + case !secretConfigured: + reason = "terminal login secret is not configured" + case !tailscale: + reason = "terminal requires a tailscale origin" + case !secure: + reason = "terminal requires https/tailscale cert" + case !authenticated: + reason = "login required" + } + + return controlTerminalStatus{ + Allowed: allowed, + Authenticated: authenticated, + Secure: secure, + Tailscale: tailscale, + LoginConfigured: secretConfigured, + Reason: reason, + Host: host, + History: history, + Suggestions: []string{ + "pwd", + "ls -la", + "tailscale status --json | sed -n '1,120p'", + "systemctl status picoclaw --no-pager", + "journalctl -u picoclaw -n 60 --no-pager", + }, + } +} + +func (m *Manager) handleTerminalLogin(w http.ResponseWriter, r *http.Request) (controlTerminalStatus, error) { + status := m.buildTerminalStatus(r) + if !status.Secure || !status.Tailscale { + return status, errors.New(status.Reason) + } + secret := controlPlaneTerminalSecret(m) + if secret == "" { + return status, errors.New("terminal login is not configured") + } + + var req struct { + Password string `json:"password"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + return status, errors.New("invalid request") + } + if strings.TrimSpace(req.Password) == "" || strings.TrimSpace(req.Password) != secret { + return status, errors.New("invalid terminal password") + } + + token, err := randomHex(24) + if err != nil { + return status, err + } + + st := getControlPlaneState(m) + st.mu.Lock() + st.terminalSessions[token] = time.Now().Add(12 * time.Hour) + st.mu.Unlock() + + http.SetCookie(w, &http.Cookie{ + Name: "pcp_terminal", + Value: token, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: 12 * 60 * 60, + }) + + return m.buildTerminalStatus(r), nil +} + +func (m *Manager) handleTerminalLogout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("pcp_terminal"); err == nil { + st := getControlPlaneState(m) + st.mu.Lock() + delete(st.terminalSessions, c.Value) + st.mu.Unlock() + } + http.SetCookie(w, &http.Cookie{ + Name: "pcp_terminal", + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + MaxAge: -1, + }) +} + +func (m *Manager) handleTerminalExec(r *http.Request) (map[string]any, error) { + status := m.buildTerminalStatus(r) + if !status.Allowed { + return map[string]any{"status": status}, errors.New(status.Reason) + } + if !status.Authenticated { + return map[string]any{"status": status}, errors.New("terminal login required") + } + + var req struct { + Command string `json:"command"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { + return map[string]any{"status": status}, errors.New("invalid request") + } + command := strings.TrimSpace(req.Command) + if command == "" { + return map[string]any{"status": status}, errors.New("command is required") + } + if len(command) > 800 { + return map[string]any{"status": status}, errors.New("command too long") + } + + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-lc", command) + cmd.Dir = m.config.WorkspacePath() + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if len(text) > 12000 { + text = text[:12000] + "\n...[truncated]" + } + + exitCode := 0 + if err != nil { + exitCode = 1 + if ee, ok := err.(*exec.ExitError); ok { + exitCode = ee.ExitCode() + } + if text == "" { + text = err.Error() + } + } + + entry := controlTerminalEntry{ + At: time.Now().Format(time.RFC3339), + Command: command, + Output: text, + ExitCode: exitCode, + Status: ternaryStatus(exitCode == 0, "ok", "error"), + } + + st := getControlPlaneState(m) + st.mu.Lock() + st.terminalHistory = append(st.terminalHistory, entry) + if len(st.terminalHistory) > 40 { + st.terminalHistory = st.terminalHistory[len(st.terminalHistory)-40:] + } + st.mu.Unlock() + + return map[string]any{"entry": entry, "status": m.buildTerminalStatus(r)}, nil +} + +func (m *Manager) isTerminalAuthenticated(r *http.Request) bool { + c, err := r.Cookie("pcp_terminal") + if err != nil || strings.TrimSpace(c.Value) == "" { + return false + } + st := getControlPlaneState(m) + st.mu.Lock() + defer st.mu.Unlock() + m.pruneTerminalSessionsLocked(st) + expiresAt, ok := st.terminalSessions[c.Value] + return ok && time.Now().Before(expiresAt) +} + +func (m *Manager) pruneTerminalSessionsLocked(st *controlPlaneState) { + now := time.Now() + for token, expiresAt := range st.terminalSessions { + if now.After(expiresAt) { + delete(st.terminalSessions, token) + } + } +} + +func (m *Manager) loadLogs(source string) []string { + st := getControlPlaneState(m) + st.mu.Lock() + actions := append([]string(nil), st.actionLog...) + st.mu.Unlock() + + switch source { + case "dashboard": + if len(actions) == 0 { + return []string{"no dashboard action log entries yet"} + } + if len(actions) > 200 { + actions = actions[len(actions)-200:] + } + return actions + default: + path := filepath.Join(m.config.WorkspacePath(), "heartbeat.log") + lines := tailFile(path, 260) + if len(lines) == 0 { + return []string{"heartbeat log not found or empty"} + } + return lines + } +} + +func (m *Manager) cachedSSHProbe(st *controlPlaneState, nodeID string, peer tailPeer) (string, string) { + st.mu.Lock() + cached, ok := st.sshCache[nodeID] + if ok && time.Since(cached.At) < 20*time.Second { + st.mu.Unlock() + return cached.Status, cached.Detail + } + st.mu.Unlock() + + status, detail := m.rawSSHProbe(nodeID, peer) + + st.mu.Lock() + st.sshCache[nodeID] = sshProbe{At: time.Now(), Status: status, Detail: detail} + st.mu.Unlock() + return status, detail +} + +func (m *Manager) rawSSHProbe(nodeID string, peer tailPeer) (string, string) { + target := "" + peerDNS := strings.TrimSuffix(peer.DNSName, ".") + peerIP := first(peer.TailscaleIPs) + switch nodeID { + case "vps": + target = strings.TrimSpace(os.Getenv("PICOCLAW_VPS_SSH_TARGET")) + if target == "" { + target = firstNonEmpty("vps", peerDNS, peerIP) + } + case "pico": + target = strings.TrimSpace(os.Getenv("PICOCLAW_PICO_SSH_TARGET")) + if target == "" { + target = firstNonEmpty("orangepi", peerDNS, peerIP) + } + case "pc": + target = strings.TrimSpace(os.Getenv("PICOCLAW_PC_SSH_TARGET")) + if target == "" { + target = firstNonEmpty("black-wave", peerDNS, peerIP) + } + case "storage": + target = strings.TrimSpace(os.Getenv("PICOCLAW_STORAGE_SSH_TARGET")) + if target == "" { + target = firstNonEmpty(peerDNS, peerIP) + } + default: + target = firstNonEmpty(peerDNS, peerIP) + } + if target == "" { + return "unknown", "no SSH target mapping" + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + cmd := exec.CommandContext( + ctx, + "ssh", + "-o", "BatchMode=yes", + "-o", "ConnectTimeout=4", + "-o", "StrictHostKeyChecking=accept-new", + target, + "echo", "ssh_ok", + ) + out, err := cmd.CombinedOutput() + text := strings.TrimSpace(string(out)) + if err != nil { + if text == "" { + text = err.Error() + } + return "warn", trimLogLine(text) + } + if strings.Contains(text, "ssh_ok") { + return "ok", "ready" + } + if strings.Contains(strings.ToLower(text), "operation not permitted") { + return "warn", "Tailscale SSH reachable, but remote command execution is denied" + } + return "warn", trimLogLine(text) +} + +type tailStatus struct { + Self tailPeer `json:"Self"` + Peer map[string]tailPeer + Peers []tailPeer +} + +type tailPeer struct { + HostName string `json:"HostName"` + DNSName string `json:"DNSName"` + OS string `json:"OS"` + Online bool `json:"Online"` + Tags []string `json:"Tags"` + TailscaleIPs []string `json:"TailscaleIPs"` + CapMap map[string]json.RawMessage `json:"CapMap"` +} + +func scoreTailPeer(nodeID string, hints []string, peer tailPeer) int { + name := strings.ToLower(peer.HostName + " " + strings.TrimSuffix(peer.DNSName, ".")) + score := 0 + hintMatched := false + for _, hint := range hints { + if !strings.Contains(name, hint) { + continue + } + hintMatched = true + score += 10 + if strings.EqualFold(peer.HostName, hint) || strings.EqualFold(strings.TrimSuffix(peer.DNSName, "."), hint) { + score += 10 + } + } + + switch nodeID { + case "pc": + if containsTag(peer.Tags, "tag:wslhost") { + hintMatched = true + score += 40 + } + if containsTag(peer.Tags, "tag:control") { + score += 20 + } + if strings.EqualFold(peer.OS, "linux") { + score += 15 + } + case "vps": + if containsTag(peer.Tags, "tag:control") { + score += 20 + } + if strings.EqualFold(peer.OS, "linux") { + score += 10 + } + case "storage": + if strings.EqualFold(peer.OS, "linux") { + score += 10 + } + } + + if !hintMatched { + return 0 + } + if peer.Online { + score += 5 + } + return score +} + +func containsTag(tags []string, want string) bool { + for _, tag := range tags { + if tag == want { + return true + } + } + return false +} + +func loadTailStatus() (tailStatus, error) { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "tailscale", "status", "--json") + out, err := cmd.Output() + if err != nil { + return tailStatus{}, err + } + var raw struct { + Self tailPeer `json:"Self"` + Peer map[string]tailPeer `json:"Peer"` + } + if err := json.Unmarshal(out, &raw); err != nil { + return tailStatus{}, err + } + peers := make([]tailPeer, 0, len(raw.Peer)) + for _, p := range raw.Peer { + peers = append(peers, p) + } + sort.Slice(peers, func(i, j int) bool { return peers[i].HostName < peers[j].HostName }) + return tailStatus{Self: raw.Self, Peer: raw.Peer, Peers: peers}, nil +} + +func tailFile(path string, n int) []string { + b, err := os.ReadFile(path) + if err != nil { + return nil + } + lines := strings.Split(strings.ReplaceAll(string(b), "\r\n", "\n"), "\n") + out := make([]string, 0, n) + for i := len(lines) - 1; i >= 0 && len(out) < n; i-- { + line := strings.TrimSpace(lines[i]) + if line == "" { + continue + } + out = append(out, line) + } + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} + +func summarizePayload(payload map[string]any) string { + if len(payload) == 0 { + return "-" + } + b, err := json.Marshal(payload) + if err != nil { + return "payload" + } + s := string(b) + if len(s) > 120 { + return s[:120] + "..." + } + return s +} + +func trimStateHistory(st *controlPlaneState) { + if len(st.jobOrder) > 200 { + st.jobOrder = st.jobOrder[len(st.jobOrder)-200:] + } + if len(st.chatHistory) > 250 { + st.chatHistory = st.chatHistory[len(st.chatHistory)-250:] + } + if len(st.actionLog) > 250 { + st.actionLog = st.actionLog[len(st.actionLog)-250:] + } + if len(st.terminalHistory) > 40 { + st.terminalHistory = st.terminalHistory[len(st.terminalHistory)-40:] + } +} + +func respondJSON(w http.ResponseWriter, code int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(data) +} + +func boolState(ok bool, fallback string) string { + if ok { + return "ok" + } + if fallback == "" { + return "warn" + } + return fallback +} + +func envState(name string) string { + if strings.TrimSpace(os.Getenv(name)) != "" { + return "ok" + } + return "warn" +} + +func errState(err error) string { + if err == nil { + return "ok" + } + return "warn" +} + +func errText(err error) string { + if err == nil { + return "" + } + return trimLogLine(err.Error()) +} + +func summarizeServiceStatuses(services []controlService) map[string]string { + out := make(map[string]string, len(services)) + for _, svc := range services { + out[svc.ID] = svc.Status + } + return out +} + +func findNodeByID(nodes []controlNode, id string) controlNode { + for _, n := range nodes { + if n.ID == id { + return n + } + } + return controlNode{ID: id} +} + +func anyRemoteWorkerReady(nodes []controlNode) bool { + for _, n := range nodes { + if n.ID != "pico" && n.WorkerReady { + return true + } + } + return false +} + +func countSecurityFindings(findings []controlSecurityFinding, severity string) int { + count := 0 + for _, f := range findings { + if f.Severity == severity { + count++ + } + } + return count +} + +func securityOverallStatus(criticalCount, warnCount int) string { + if criticalCount > 0 { + return "error" + } + if warnCount > 0 { + return "warn" + } + return "ok" +} + +func securityDomainStatus(ok bool, partial bool) string { + if ok { + return "ok" + } + if partial { + return "warn" + } + return "error" +} + +func securityDomainScore(ok bool, partial bool) int { + if ok { + return 92 + } + if partial { + return 64 + } + return 28 +} + +func countAuthProvidersByStatus(providers []controlAuthProvider, status string) int { + count := 0 + for _, p := range providers { + if p.Status == status { + count++ + } + } + return count +} + +func compactSignals(in []string) []string { + out := make([]string, 0, len(in)) + for _, item := range in { + item = strings.TrimSpace(item) + if item == "" { + continue + } + out = append(out, item) + } + return out +} + +func serviceStatusIs(services []controlService, id, want string) bool { + for _, svc := range services { + if svc.ID == id { + return svc.Status == want + } + } + return false +} + +func ternaryText(cond bool, yes, no string) string { + if cond { + return yes + } + return no +} + +func ternaryInt(cond bool, yes, no int) int { + if cond { + return yes + } + return no +} + +func detectTailscaleCertFiles(hostname, workspace string) []string { + hostnames := compactSignals([]string{ + hostname, + firstNonEmpty(hostname, strings.TrimSpace(os.Getenv("TS_CERT_HOSTNAME"))), + }) + roots := []string{ + homeDir(), + workspace, + filepath.Join(homeDir(), ".config", "tailscale"), + filepath.Join(homeDir(), ".tailscale"), + } + seen := map[string]bool{} + files := []string{} + for _, host := range hostnames { + for _, root := range roots { + for _, ext := range []string{".crt", ".key"} { + path := filepath.Join(root, host+ext) + if fileExists(path) && !seen[path] { + seen[path] = true + files = append(files, path) + } + } + } + } + sort.Strings(files) + return files +} + +func controlPlanePublicBase(selfNode controlNode, serveConfig bool) string { + if selfNode.TailscaleIP != "" { + return "http://" + selfNode.TailscaleIP + ":3000" + } + if serveConfig && selfNode.Hostname != "" { + return "http://" + selfNode.Hostname + ":3000" + } + return "" +} + +func controlPlaneHTTPSBase(selfNode controlNode, certFiles []string) string { + if len(certFiles) == 0 { + return "" + } + if selfNode.Hostname != "" { + return "https://" + selfNode.Hostname + } + return "" +} + +func joinURL(base, path string) string { + base = strings.TrimRight(strings.TrimSpace(base), "/") + path = "/" + strings.TrimLeft(strings.TrimSpace(path), "/") + if base == "" { + return "" + } + return base + path +} + +func authProviderState(state controlAuth, provider string) string { + for _, p := range state.Providers { + if p.Provider == provider { + return p.Status + } + } + return "warn" +} + +func stateFromActive(jobID string) string { + if jobID != "" { + return "running" + } + return "ok" +} + +func processMetrics() string { + var ms runtime.MemStats + runtime.ReadMemStats(&ms) + return fmt.Sprintf("cpu=%d goroutines=%d heap=%s", runtime.NumCPU(), runtime.NumGoroutine(), humanSize(int64(ms.Alloc))) +} + +func hasSSHCap(capMap map[string]json.RawMessage) bool { + if len(capMap) == 0 { + return false + } + _, ok := capMap["https://tailscale.com/cap/ssh"] + return ok +} + +func tagsState(tags []string) string { + if len(tags) == 0 { + return "missing" + } + return strings.Join(tags, ",") +} + +func onlineText(online bool) string { + if online { + return "reachable" + } + return "offline" +} + +func inferredServices(role string) []string { + switch role { + case "gateway": + return []string{"reverse-proxy", "gateway"} + case "pc-worker": + return []string{"worker-api", "build-runner"} + case "storage": + return []string{"artifact-storage", "samba"} + default: + return []string{"worker"} + } +} + +func first(in []string) string { + if len(in) == 0 { + return "" + } + return in[0] +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + v = strings.TrimSpace(v) + if v != "" { + return v + } + } + return "" +} + +func ternaryStatus(ok bool, whenOK, whenFalse string) string { + if ok { + return whenOK + } + return whenFalse +} + +func hasArtifactKind(artifacts []controlArtifact, kind string) bool { + for _, a := range artifacts { + if a.Kind == kind { + return true + } + } + return false +} + +func humanSize(n int64) string { + if n < 1024 { + return fmt.Sprintf("%dB", n) + } + units := []string{"B", "KB", "MB", "GB", "TB"} + f := float64(n) + i := 0 + for f >= 1024 && i < len(units)-1 { + f /= 1024 + i++ + } + return fmt.Sprintf("%.1f%s", f, units[i]) +} + +func controlPlaneTerminalSecret(m *Manager) string { + if v := strings.TrimSpace(os.Getenv("PICOCLAW_TERMINAL_PASSWORD")); v != "" { + return v + } + tokenPath := filepath.Join(m.config.WorkspacePath(), ".control-plane-terminal-token") + if b, err := os.ReadFile(tokenPath); err == nil { + if v := strings.TrimSpace(string(b)); v != "" { + return v + } + } + return strings.TrimSpace(m.config.Channels.Pico.Token) +} + +func isSecureControlRequest(r *http.Request) bool { + if r == nil { + return false + } + if r.TLS != nil { + return true + } + if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") { + return true + } + host := hostOnly(r.Host) + return strings.Contains(host, ".ts.net") +} + +func isTailscaleControlRequest(r *http.Request) bool { + if r == nil { + return false + } + host := hostOnly(r.Host) + if strings.Contains(host, ".ts.net") { + return true + } + for _, candidate := range []string{ + strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-For"), ",")[0]), + strings.TrimSpace(r.Header.Get("X-Real-IP")), + remoteAddrIP(r.RemoteAddr), + } { + if candidate == "" { + continue + } + ip := net.ParseIP(candidate) + if ip == nil { + continue + } + if isTailscaleIP(ip) || ip.IsLoopback() { + return true + } + } + return false +} + +func isTailscaleIP(ip net.IP) bool { + if ip == nil { + return false + } + if v4 := ip.To4(); v4 != nil { + return v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 + } + return strings.HasPrefix(strings.ToLower(ip.String()), "fd7a:115c:a1e0:") +} + +func remoteAddrIP(addr string) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err == nil { + return host + } + return strings.TrimSpace(addr) +} + +func hostOnly(addr string) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err == nil { + return host + } + return strings.TrimSpace(addr) +} + +func randomHex(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func desiredCodexExecScript() string { + return "#!/usr/bin/env bash\n" + + "set -euo pipefail\n\n" + + "TS_HOST=\"${TS_HOST:-black-wave}\"\n" + + "LOCAL_HOST=\"$(hostname)\"\n" + + "LOCAL_IP=\"$(tailscale ip -4 2>/dev/null | head -n1 || echo '')\"\n\n" + + "if [ \"$#\" -lt 1 ]; then\n" + + " echo \"usage: wsl-codex-exec [target]\"\n" + + " exit 2\n" + + "fi\n\n" + + "is_local() {\n" + + " local target=\"$1\"\n" + + " [[ \"$target\" == \"localhost\" ]] || [[ \"$target\" == \"$LOCAL_HOST\" ]] || [[ \"$target\" == \"$LOCAL_IP\" ]] || [[ \"$target\" == \"127.0.0.1\" ]]\n" + + "}\n\n" + + "run_picoclaw_local() {\n" + + " local prompt=\"$1\"\n" + + " /usr/local/bin/picoclaw agent --model \"${PICO_CLI_MODEL:-gpt-5.2}\" -m \"$prompt\"\n" + + "}\n\n" + + "run_remote_codex() {\n" + + " local target=\"$1\"\n" + + " local prompt=\"$2\"\n" + + " local remote_cmd=\"cat >/tmp/codex_prompt.txt && chown caps:caps /tmp/codex_prompt.txt && su - caps -c 'cd /home/caps && codex exec --sandbox workspace-write --ask-for-approval never \\\"\\$(cat /tmp/codex_prompt.txt)\\\"'\"\n" + + " printf '%s' \"$prompt\" | tailscale ssh root@\"$target\" \"$remote_cmd\"\n" + + "}\n\n" + + "CMD=\"$1\"\n" + + "TARGET=\"${2:-$TS_HOST}\"\n\n" + + "case \"$CMD\" in\n" + + " health-check)\n" + + " if is_local \"$TARGET\"; then\n" + + " echo \"=== LOCAL PICO HEALTH CHECK ===\"\n" + + " echo \"Host: $(hostname)\"\n" + + " echo \"User: $(whoami)\"\n" + + " uptime\n" + + " df -h /\n" + + " echo \"Fallback: local picoclaw agent available\"\n" + + " else\n" + + " echo \"=== REMOTE HEALTH CHECK ($TARGET) ===\"\n" + + " if ! tailscale ssh root@\"$TARGET\" 'echo \"Host: $(hostname)\"; echo \"User: $(whoami)\"; uptime; df -h /' ; then\n" + + " echo \"remote tailscale ssh unavailable; local pico fallback ready\"\n" + + " fi\n" + + " fi\n" + + " ;;\n" + + " status)\n" + + " if is_local \"$TARGET\"; then\n" + + " echo \"=== LOCAL STATUS ===\"\n" + + " tailscale status 2>/dev/null || true\n" + + " systemctl --failed 2>/dev/null || true\n" + + " echo \"model=${PICO_CLI_MODEL:-gpt-5.2}\"\n" + + " else\n" + + " echo \"=== REMOTE STATUS ($TARGET) ===\"\n" + + " if ! tailscale ssh root@\"$TARGET\" 'tailscale status 2>/dev/null || true; systemctl --failed 2>/dev/null || true' ; then\n" + + " echo \"remote tailscale ssh unavailable; local pico fallback ready\"\n" + + " fi\n" + + " fi\n" + + " ;;\n" + + " *)\n" + + " PROMPT=\"$CMD\"\n" + + " if is_local \"$TARGET\"; then\n" + + " echo \"[LOCAL PICO AGENT]\"\n" + + " run_picoclaw_local \"$PROMPT\"\n" + + " else\n" + + " echo \"[REMOTE EXEC -> $TARGET]\"\n" + + " if ! run_remote_codex \"$TARGET\" \"$PROMPT\"; then\n" + + " echo \"[REMOTE FAILED -> LOCAL PICO AGENT FALLBACK]\" >&2\n" + + " run_picoclaw_local \"$PROMPT\"\n" + + " fi\n" + + " fi\n" + + " ;;\n" + + "esac\n" +} + +func desiredNodeExecScript() string { + return "#!/usr/bin/env bash\n" + + "set -euo pipefail\n\n" + + "TARGET=\"${1:-}\"\n" + + "shift || true\n" + + "CMD=\"$*\"\n" + + "LOCAL_HOST=\"$(hostname)\"\n" + + "TS_IP=\"$(tailscale ip -4 2>/dev/null | head -n1 || true)\"\n\n" + + "if [[ -z \"$TARGET\" || \"$TARGET\" == \"localhost\" || \"$TARGET\" == \"$LOCAL_HOST\" || \"$TARGET\" == \"$TS_IP\" ]]; then\n" + + " exec bash -lc \"$CMD\"\n" + + "fi\n\n" + + "exec tailscale ssh \"root@${TARGET}\" \"$CMD\"\n" +} + +func desiredNodeSSHScript() string { + return "#!/usr/bin/env bash\n" + + "set -euo pipefail\n\n" + + "if [ $# -lt 2 ]; then\n" + + " echo \"Usage: $0 \"\n" + + " exit 1\n" + + "fi\n\n" + + "HOST=\"$1\"\n" + + "shift\n" + + "exec tailscale ssh \"root@${HOST}\" \"$*\"\n" +} + +func homeDir() string { + h, err := os.UserHomeDir() + if err != nil { + return "/root" + } + return h +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func httpProbe(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer res.Body.Close() + body, _ := io.ReadAll(io.LimitReader(res.Body, 1024)) + text := strings.TrimSpace(string(body)) + if len(text) > 160 { + text = text[:160] + "..." + } + return fmt.Sprintf("%s status=%d body=%s", url, res.StatusCode, text), nil +} diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index cdd49538f..a99e167a4 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -10,8 +10,17 @@ import ( "context" "errors" "fmt" + "hash/fnv" + "html/template" "math" + "net" "net/http" + "os" + "os/exec" + "path" + "path/filepath" + "sort" + "strings" "sync" "time" @@ -36,6 +45,7 @@ const ( janitorInterval = 10 * time.Second typingStopTTL = 5 * time.Minute placeholderTTL = 10 * time.Minute + previewHost = "0.0.0.0" ) // typingEntry wraps a typing stop function with a creation timestamp for TTL eviction. @@ -66,6 +76,158 @@ var channelRateConfig = map[string]float64{ "irc": 2, } +var rootDashboardTemplate = template.Must(template.New("root_dashboard").Parse(` + + + + + PicoClaw Dashboard + + + +
        +
        +
        +

        PicoClaw Dashboard

        +

        Live runtime overview for channels and health endpoints

        +
        +
        Generated {{.GeneratedAt}}
        +
        + +
        +
        +
        Service
        +
        picoclaw online
        +
        +
        +
        Gateway
        +
        {{.Address}}
        +
        +
        +
        Channels Enabled
        +
        {{len .Channels}}
        +
        +
        +
        Health
        + +
        +
        + +
        +
        Channel List
        + {{if .Channels}} +
        + {{range .Channels}}{{.}}{{end}} +
        + {{else}} +

        No channels currently registered.

        + {{end}} +
        + +
        +
        Webhook Routes
        + {{if .Webhooks}} +
          + {{range .Webhooks}} +
        • {{.Name}}: {{.Path}}
        • + {{end}} +
        + {{else}} +

        No webhook routes registered.

        + {{end}} +
        + + +
        + + +`)) + +type dashboardWebhook struct { + Name string + Path string +} + +type dashboardViewData struct { + Address string + GeneratedAt string + Channels []string + Webhooks []dashboardWebhook +} + type channelWorker struct { ch Channel queue chan bus.OutboundMessage @@ -75,19 +237,30 @@ type channelWorker struct { limiter *rate.Limiter } +type previewMount struct { + Root string + Entry string + CreatedAt time.Time + UpdatedAt time.Time +} + type Manager struct { - channels map[string]Channel - workers map[string]*channelWorker - bus *bus.MessageBus - config *config.Config - mediaStore media.MediaStore - dispatchTask *asyncTask - mux *http.ServeMux - httpServer *http.Server - mu sync.RWMutex - placeholders sync.Map // "channel:chatID" → placeholderID (string) - typingStops sync.Map // "channel:chatID" → func() - reactionUndos sync.Map // "channel:chatID" → reactionEntry + channels map[string]Channel + workers map[string]*channelWorker + bus *bus.MessageBus + config *config.Config + mediaStore media.MediaStore + dispatchTask *asyncTask + mux *http.ServeMux + httpServer *http.Server + previewServer *http.Server + previewAddr string + previews map[string]previewMount + controlPlaneDiagnoser func(context.Context, string) (string, error) + mu sync.RWMutex + placeholders sync.Map // "channel:chatID" → placeholderID (string) + typingStops sync.Map // "channel:chatID" → func() + reactionUndos sync.Map // "channel:chatID" → reactionEntry } type asyncTask struct { @@ -156,6 +329,7 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi bus: messageBus, config: cfg, mediaStore: store, + previews: make(map[string]previewMount), } if err := m.initChannels(); err != nil { @@ -165,6 +339,14 @@ func NewManager(cfg *config.Config, messageBus *bus.MessageBus, store media.Medi return m, nil } +// SetControlPlaneDiagnoser injects a callback used by the control-plane UI when +// an operator requests an LLM-backed diagnosis from the backend. +func (m *Manager) SetControlPlaneDiagnoser(fn func(context.Context, string) (string, error)) { + m.mu.Lock() + defer m.mu.Unlock() + m.controlPlaneDiagnoser = fn +} + // initChannel is a helper that looks up a factory by name and creates the channel. func (m *Manager) initChannel(name, displayName string) { f, ok := getFactory(name) @@ -293,6 +475,47 @@ func (m *Manager) initChannels() error { func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { m.mux = http.NewServeMux() + // Register the control-plane dashboard and API (root endpoint). + m.registerControlPlaneRoutes(addr) + m.setupPreviewServer(addr) + + // Keep the original lightweight dashboard available as fallback. + m.mux.HandleFunc("/legacy-dashboard", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/legacy-dashboard" { + http.NotFound(w, r) + return + } + + data := dashboardViewData{ + Address: addr, + GeneratedAt: time.Now().Format(time.RFC3339), + Channels: make([]string, 0, len(m.channels)), + Webhooks: make([]dashboardWebhook, 0, len(m.channels)), + } + + m.mu.RLock() + for name, ch := range m.channels { + data.Channels = append(data.Channels, name) + if wh, ok := ch.(WebhookHandler); ok { + data.Webhooks = append(data.Webhooks, dashboardWebhook{ + Name: name, + Path: wh.WebhookPath(), + }) + } + } + m.mu.RUnlock() + + sort.Strings(data.Channels) + sort.Slice(data.Webhooks, func(i, j int) bool { return data.Webhooks[i].Name < data.Webhooks[j].Name }) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := rootDashboardTemplate.Execute(w, data); err != nil { + logger.ErrorCF("channels", "legacy dashboard render failed", map[string]any{"error": err.Error()}) + http.Error(w, "legacy dashboard render failed", http.StatusInternalServerError) + return + } + }) + // Register health endpoints if healthServer != nil { healthServer.RegisterOnMux(m.mux) @@ -324,6 +547,245 @@ func (m *Manager) SetupHTTPServer(addr string, healthServer *health.Server) { } } +func (m *Manager) setupPreviewServer(addr string) { + previewAddr := derivePreviewAddr(addr) + previewMux := http.NewServeMux() + previewMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("PicoClaw preview server\n")) + }) + previewMux.HandleFunc("/preview/", m.handlePreviewRequest) + m.previewAddr = previewAddr + m.previewServer = &http.Server{ + Addr: previewAddr, + Handler: previewMux, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + } +} + +func derivePreviewAddr(addr string) string { + _, port := splitHostPort(addr) + if port <= 0 { + port = 3001 + } + return net.JoinHostPort(previewHost, fmt.Sprintf("%d", port+1)) +} + +func splitHostPort(addr string) (string, int) { + host, portStr, err := net.SplitHostPort(strings.TrimSpace(addr)) + if err != nil { + return "", 0 + } + port, err := net.LookupPort("tcp", portStr) + if err != nil { + return host, 0 + } + return host, port +} + +func sanitizePreviewSlug(raw string) string { + raw = strings.TrimSpace(strings.ToLower(raw)) + if raw == "" { + return "" + } + var b strings.Builder + prevDash := false + for _, r := range raw { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + prevDash = false + continue + } + if prevDash { + continue + } + b.WriteByte('-') + prevDash = true + } + return strings.Trim(b.String(), "-") +} + +func defaultPreviewSlug(root string) string { + base := sanitizePreviewSlug(filepath.Base(strings.TrimSpace(root))) + if base == "" { + base = "preview" + } + h := fnv.New32a() + _, _ = h.Write([]byte(root)) + return fmt.Sprintf("%s-%08x", base, h.Sum32()) +} + +func (m *Manager) PublishPreview(root, entry, slug string) (string, string, string, error) { + root = strings.TrimSpace(root) + if root == "" { + return "", "", "", fmt.Errorf("preview root is required") + } + absRoot, err := filepath.Abs(root) + if err != nil { + return "", "", "", fmt.Errorf("resolve preview root: %w", err) + } + info, err := os.Stat(absRoot) + if err != nil { + return "", "", "", fmt.Errorf("preview root not found: %w", err) + } + if !info.IsDir() { + return "", "", "", fmt.Errorf("preview root must be a directory") + } + + entry = strings.TrimSpace(strings.TrimLeft(filepath.ToSlash(entry), "/")) + if entry != "" { + if strings.HasPrefix(entry, "../") || entry == ".." { + return "", "", "", fmt.Errorf("preview entry escapes hosted directory") + } + entryPath := filepath.Join(absRoot, filepath.FromSlash(entry)) + entryInfo, statErr := os.Stat(entryPath) + if statErr != nil { + return "", "", "", fmt.Errorf("preview entry not found: %w", statErr) + } + if entryInfo.IsDir() { + entry = strings.TrimSuffix(entry, "/") + "/index.html" + } + } else if _, statErr := os.Stat(filepath.Join(absRoot, "index.html")); statErr == nil { + entry = "index.html" + } + + slug = sanitizePreviewSlug(slug) + if slug == "" { + slug = defaultPreviewSlug(absRoot) + } + + now := time.Now() + m.mu.Lock() + createdAt := now + if existing, ok := m.previews[slug]; ok { + createdAt = existing.CreatedAt + } + m.previews[slug] = previewMount{Root: absRoot, Entry: entry, CreatedAt: createdAt, UpdatedAt: now} + m.mu.Unlock() + + tailscaleBase, localBase := m.previewBases() + return slug, buildPreviewURL(tailscaleBase, slug, entry), buildPreviewURL(localBase, slug, entry), nil +} + +func (m *Manager) previewBases() (string, string) { + _, port := splitHostPort(m.previewAddr) + if port <= 0 { + port = 3002 + } + localBase := fmt.Sprintf("http://127.0.0.1:%d", port) + tailscaleIP := detectTailscaleIPv4() + if tailscaleIP == "" { + return "", localBase + } + return fmt.Sprintf("http://%s:%d", tailscaleIP, port), localBase +} + +func detectTailscaleIPv4() string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "tailscale", "ip", "-4") + out, err := cmd.Output() + if err != nil { + return "" + } + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line != "" { + return line + } + } + return "" +} + +func buildPreviewURL(base, slug, entry string) string { + if strings.TrimSpace(base) == "" || strings.TrimSpace(slug) == "" { + return "" + } + base = strings.TrimRight(base, "/") + urlPath := "/preview/" + slug + "/" + if entry != "" { + urlPath += strings.TrimLeft(filepath.ToSlash(entry), "/") + } + return base + urlPath +} + +func (m *Manager) handlePreviewRequest(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + trimmed := strings.TrimPrefix(path.Clean(r.URL.Path), "/preview/") + if trimmed == "." || trimmed == "" || strings.HasPrefix(trimmed, "../") { + http.NotFound(w, r) + return + } + parts := strings.SplitN(trimmed, "/", 2) + slug := parts[0] + relPath := "" + if len(parts) == 2 { + relPath = strings.TrimPrefix(parts[1], "/") + } + + m.mu.RLock() + mount, ok := m.previews[slug] + m.mu.RUnlock() + if !ok { + http.NotFound(w, r) + return + } + + if relPath == "" { + relPath = mount.Entry + } + if relPath == "" { + relPath = "." + } + relPath = strings.TrimPrefix(path.Clean("/"+relPath), "/") + if strings.HasPrefix(relPath, "../") || relPath == ".." { + http.NotFound(w, r) + return + } + + target := filepath.Join(mount.Root, filepath.FromSlash(relPath)) + if !isPreviewPathWithin(target, mount.Root) { + http.NotFound(w, r) + return + } + if info, err := os.Stat(target); err == nil { + if info.IsDir() { + indexPath := filepath.Join(target, "index.html") + if _, statErr := os.Stat(indexPath); statErr == nil { + http.ServeFile(w, r, indexPath) + return + } + } else { + http.ServeFile(w, r, target) + return + } + } + + if mount.Entry != "" && path.Ext(relPath) == "" { + fallback := filepath.Join(mount.Root, filepath.FromSlash(mount.Entry)) + if isPreviewPathWithin(fallback, mount.Root) { + if _, err := os.Stat(fallback); err == nil { + http.ServeFile(w, r, fallback) + return + } + } + } + + http.NotFound(w, r) +} + +func isPreviewPathWithin(candidate, root string) bool { + root = filepath.Clean(root) + candidate = filepath.Clean(candidate) + rel, err := filepath.Rel(root, candidate) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) +} + func (m *Manager) StartAll(ctx context.Context) error { m.mu.Lock() defer m.mu.Unlock() @@ -377,6 +839,19 @@ func (m *Manager) StartAll(ctx context.Context) error { }() } + if m.previewServer != nil { + go func() { + logger.InfoCF("channels", "Preview server listening", map[string]any{ + "addr": m.previewServer.Addr, + }) + if err := m.previewServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.ErrorCF("channels", "Preview server error", map[string]any{ + "error": err.Error(), + }) + } + }() + } + logger.InfoC("channels", "All channels started") return nil } @@ -399,6 +874,17 @@ func (m *Manager) StopAll(ctx context.Context) error { m.httpServer = nil } + if m.previewServer != nil { + shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if err := m.previewServer.Shutdown(shutdownCtx); err != nil { + logger.ErrorCF("channels", "Preview server shutdown error", map[string]any{ + "error": err.Error(), + }) + } + m.previewServer = nil + } + // Cancel dispatcher if m.dispatchTask != nil { m.dispatchTask.cancel() diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 0a36247a6..8dc62787b 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -6,9 +6,11 @@ import ( "net/http" "net/url" "os" + "path/filepath" "regexp" "strconv" "strings" + "sync" "time" "github.com/mymmrac/telego" @@ -26,16 +28,17 @@ import ( ) var ( - reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`) - reBlockquote = regexp.MustCompile(`^>\s*(.*)$`) - reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) - reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) - reBoldUnder = regexp.MustCompile(`__(.+?)__`) - reItalic = regexp.MustCompile(`_([^_]+)_`) - reStrike = regexp.MustCompile(`~~(.+?)~~`) - reListItem = regexp.MustCompile(`^[-*]\s+`) - reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") - reInlineCode = regexp.MustCompile("`([^`]+)`") + reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`) + reBlockquote = regexp.MustCompile(`(?m)^>\s*(.*)$`) + reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + reBoldItalicStar = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`) + reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`) + reBoldUnder = regexp.MustCompile(`__(.+?)__`) + reItalic = regexp.MustCompile(`_([^_]+)_`) + reStrike = regexp.MustCompile(`~~(.+?)~~`) + reListItem = regexp.MustCompile(`(?m)^[-*]\s+`) + reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") + reInlineCode = regexp.MustCompile("`([^`]+)`") ) type TelegramChannel struct { @@ -47,6 +50,11 @@ type TelegramChannel struct { ctx context.Context cancel context.CancelFunc + mediaMu sync.Mutex + lastMediaRefByChat map[string]string + lastMediaNameByChat map[string]string + lastMediaSeenByChat map[string]time.Time + registerFunc func(context.Context, []commands.Definition) error commandRegCancel context.CancelFunc } @@ -94,10 +102,13 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann ) return &TelegramChannel{ - BaseChannel: base, - bot: bot, - config: cfg, - chatIDs: make(map[string]int64), + BaseChannel: base, + bot: bot, + config: cfg, + chatIDs: make(map[string]int64), + lastMediaRefByChat: make(map[string]string), + lastMediaNameByChat: make(map[string]string), + lastMediaSeenByChat: make(map[string]time.Time), }, nil } @@ -185,6 +196,12 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err chunk := queue[0] queue = queue[1:] + logger.InfoCF("telegram", "Outbound text", map[string]any{ + "chat_id": msg.ChatID, + "content_len": len(chunk), + "preview": utils.Truncate(chunk, 160), + }) + htmlContent := markdownToTelegramHTML(chunk) if len([]rune(htmlContent)) > 4096 { @@ -232,6 +249,11 @@ func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlC // (Telegram's typing indicator expires after ~5s) in a background goroutine. // The returned stop function is idempotent and cancels the goroutine. func (c *TelegramChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + // Respect channel-level typing config; disabled means no-op. + if !c.config.Channels.Telegram.Typing.Enabled { + return func() {}, nil + } + cid, err := parseChatID(chatID) if err != nil { return func() {}, err @@ -327,6 +349,13 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe continue } + logger.InfoCF("telegram", "Outbound media", map[string]any{ + "chat_id": msg.ChatID, + "type": part.Type, + "caption": utils.Truncate(part.Caption, 160), + "local_path": localPath, + }) + file, err := os.Open(localPath) if err != nil { logger.ErrorCF("telegram", "Failed to open media file", map[string]any{ @@ -351,6 +380,13 @@ func (c *TelegramChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMe Caption: part.Caption, } _, err = c.bot.SendAudio(ctx, params) + case "voice": + params := &telego.SendVoiceParams{ + ChatID: tu.ID(chatID), + Voice: telego.InputFile{File: file}, + Caption: part.Caption, + } + _, err = c.bot.SendVoice(ctx, params) case "video": params := &telego.SendVideoParams{ ChatID: tu.ID(chatID), @@ -419,19 +455,26 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes scope := channels.BuildMediaScope("telegram", chatIDStr, messageIDStr) // Helper to register a local file with the media store - storeMedia := func(localPath, filename string) string { + storeMedia := func(localPath string, meta media.MediaMeta) string { + if strings.TrimSpace(meta.Filename) == "" { + meta.Filename = filepath.Base(localPath) + } + if strings.TrimSpace(meta.Source) == "" { + meta.Source = "telegram" + } + if store := c.GetMediaStore(); store != nil { - ref, err := store.Store(localPath, media.MediaMeta{ - Filename: filename, - Source: "telegram", - }, scope) + ref, err := store.Store(localPath, meta, scope) if err == nil { return ref } + logger.WarnCF("telegram", "Failed to store media in media store", map[string]any{"error": err.Error()}) } return localPath // fallback: use raw path } + lastMediaName := "" + if message.Text != "" { content += message.Text } @@ -447,7 +490,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes photo := message.Photo[len(message.Photo)-1] photoPath := c.downloadPhoto(ctx, photo.FileID) if photoPath != "" { - mediaPaths = append(mediaPaths, storeMedia(photoPath, "photo.jpg")) + mediaPaths = append(mediaPaths, storeMedia(photoPath, media.MediaMeta{Filename: "photo.jpg", ContentType: "image/jpeg", Source: "telegram"})) + lastMediaName = "photo.jpg" if content != "" { content += "\n" } @@ -458,7 +502,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Voice != nil { voicePath := c.downloadFile(ctx, message.Voice.FileID, ".ogg") if voicePath != "" { - mediaPaths = append(mediaPaths, storeMedia(voicePath, "voice.ogg")) + mediaPaths = append(mediaPaths, storeMedia(voicePath, media.MediaMeta{Filename: "voice.ogg", ContentType: "audio/ogg", Source: "telegram"})) + lastMediaName = "voice.ogg" if content != "" { content += "\n" @@ -470,7 +515,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes if message.Audio != nil { audioPath := c.downloadFile(ctx, message.Audio.FileID, ".mp3") if audioPath != "" { - mediaPaths = append(mediaPaths, storeMedia(audioPath, "audio.mp3")) + mediaPaths = append(mediaPaths, storeMedia(audioPath, media.MediaMeta{Filename: "audio.mp3", ContentType: "audio/mpeg", Source: "telegram"})) + lastMediaName = "audio.mp3" if content != "" { content += "\n" } @@ -478,14 +524,99 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes } } - if message.Document != nil { - docPath := c.downloadFile(ctx, message.Document.FileID, "") - if docPath != "" { - mediaPaths = append(mediaPaths, storeMedia(docPath, "document")) + if message.Video != nil { + videoType := strings.TrimSpace(message.Video.MimeType) + videoExt := ".mp4" + if strings.Contains(strings.ToLower(videoType), "webm") { + videoExt = ".webm" + } + videoPath := c.downloadFile(ctx, message.Video.FileID, videoExt) + if videoPath != "" { + if videoType == "" { + videoType = "video/mp4" + } + videoName := "video" + videoExt + mediaPaths = append(mediaPaths, storeMedia(videoPath, media.MediaMeta{Filename: videoName, ContentType: videoType, Source: "telegram"})) + lastMediaName = videoName if content != "" { content += "\n" } - content += "[file]" + content += "[video]" + } + } + + if message.VideoNote != nil { + videoNotePath := c.downloadFile(ctx, message.VideoNote.FileID, ".mp4") + if videoNotePath != "" { + mediaPaths = append(mediaPaths, storeMedia(videoNotePath, media.MediaMeta{Filename: "video-note.mp4", ContentType: "video/mp4", Source: "telegram"})) + lastMediaName = "video-note.mp4" + if content != "" { + content += "\n" + } + content += "[video note]" + } + } + + if message.Animation != nil { + animationType := strings.TrimSpace(message.Animation.MimeType) + animationExt := ".mp4" + switch { + case strings.Contains(strings.ToLower(animationType), "gif"): + animationExt = ".gif" + case strings.Contains(strings.ToLower(animationType), "webm"): + animationExt = ".webm" + } + animationPath := c.downloadFile(ctx, message.Animation.FileID, animationExt) + if animationPath != "" { + if animationType == "" { + animationType = "video/mp4" + } + animationName := "animation" + animationExt + mediaPaths = append(mediaPaths, storeMedia(animationPath, media.MediaMeta{Filename: animationName, ContentType: animationType, Source: "telegram"})) + lastMediaName = animationName + if content != "" { + content += "\n" + } + content += "[animation]" + } + } + + if message.Document != nil { + docPath := c.downloadFile(ctx, message.Document.FileID, "") + if docPath != "" { + docName := strings.TrimSpace(message.Document.FileName) + if docName == "" { + docName = filepath.Base(docPath) + } + docType := strings.TrimSpace(message.Document.MimeType) + mediaPaths = append(mediaPaths, storeMedia(docPath, media.MediaMeta{Filename: docName, ContentType: docType, Source: "telegram"})) + lastMediaName = docName + if content != "" { + content += "\n" + } + content += "[file: " + docName + "]" + } + } + + if len(mediaPaths) == 0 && shouldReuseRecentAttachment(content) { + if ref, name, ok := c.recallRecentMedia(chatIDStr, 30*time.Minute); ok { + mediaPaths = append(mediaPaths, ref) + if strings.TrimSpace(name) != "" { + content += "\n[file: " + name + "]" + } else { + content += "\n[file: attachment]" + } + lastMediaName = name + } + } + + if len(mediaPaths) > 0 { + c.rememberRecentMedia(chatIDStr, mediaPaths[len(mediaPaths)-1], lastMediaName) + if wantsRomanianTranslation(content) { + content += "\n[task: translate attached file to Romanian directly after reading it]" + if wantsPDFReturn(content) { + content += "\n[task: return the translated result as a PDF file]" + } } } @@ -506,6 +637,13 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes content = cleaned } + logger.InfoCF("telegram", "Inbound message", map[string]any{ + "sender_id": sender.CanonicalID, + "chat_id": fmt.Sprintf("%d", chatID), + "message_id": messageIDStr, + "media_count": len(mediaPaths), + "preview": utils.Truncate(content, 160), + }) logger.DebugCF("telegram", "Received message", map[string]any{ "sender_id": sender.CanonicalID, "chat_id": fmt.Sprintf("%d", chatID), @@ -525,10 +663,24 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes messageID := fmt.Sprintf("%d", message.MessageID) metadata := map[string]string{ - "user_id": fmt.Sprintf("%d", user.ID), - "username": user.Username, - "first_name": user.FirstName, - "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), + "user_id": fmt.Sprintf("%d", user.ID), + "username": user.Username, + "first_name": user.FirstName, + "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), + "user_profile_key": sender.CanonicalID, + } + + if adminBody, adminRequested := parseAdminEscalation(content, c.botUsername()); adminRequested { + if !c.isAdminUser(platformID) { + return c.sendPlainText(ctx, chatID, "Admin escalation denied for this Telegram account.") + } + if strings.TrimSpace(adminBody) == "" { + return c.sendPlainText(ctx, chatID, "Usage: /admin ") + } + content = adminBody + metadata["admin_escalated"] = "true" + metadata["admin_user_id"] = platformID + metadata["admin_user"] = sender.CanonicalID } c.HandleMessage(c.ctx, @@ -544,6 +696,61 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes return nil } +func (c *TelegramChannel) isAdminUser(userID string) bool { + for _, allowed := range c.config.Channels.Telegram.AdminIDs { + if strings.TrimSpace(allowed) == strings.TrimSpace(userID) { + return true + } + } + return false +} + +func (c *TelegramChannel) botUsername() string { + if c == nil || c.bot == nil { + return "" + } + return c.bot.Username() +} + +func parseAdminEscalation(content, botUsername string) (string, bool) { + trimmed := strings.TrimSpace(content) + if trimmed == "" { + return "", false + } + + lower := strings.ToLower(trimmed) + switch { + case lower == "/admin": + return "", true + case strings.HasPrefix(lower, "/admin "): + return strings.TrimSpace(trimmed[len("/admin "):]), true + case botUsername != "" && (lower == strings.ToLower("/admin@"+botUsername)): + return "", true + case botUsername != "" && strings.HasPrefix(lower, strings.ToLower("/admin@"+botUsername+" ")): + prefixLen := len("/admin@" + botUsername + " ") + return strings.TrimSpace(trimmed[prefixLen:]), true + case lower == "!admin": + return "", true + case strings.HasPrefix(lower, "!admin "): + return strings.TrimSpace(trimmed[len("!admin "):]), true + default: + return "", false + } +} + +func (c *TelegramChannel) sendPlainText(ctx context.Context, chatID int64, text string) error { + if strings.TrimSpace(text) == "" { + return nil + } + logger.InfoCF("telegram", "Outbound text", map[string]any{ + "chat_id": fmt.Sprintf("%d", chatID), + "content_len": len(text), + "preview": utils.Truncate(text, 160), + }) + _, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), text)) + return err +} + func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { @@ -608,6 +815,8 @@ func markdownToTelegramHTML(text string) string { text = reLink.ReplaceAllString(text, `$1`) + text = reBoldItalicStar.ReplaceAllString(text, "$1") + text = reBoldStar.ReplaceAllString(text, "$1") text = reBoldUnder.ReplaceAllString(text, "$1") @@ -620,6 +829,8 @@ func markdownToTelegramHTML(text string) string { return "" + match[1] + "" }) + text = replaceSingleAsteriskItalics(text) + text = reStrike.ReplaceAllString(text, "$1") text = reListItem.ReplaceAllString(text, "• ") @@ -694,6 +905,53 @@ func escapeHTML(text string) string { return text } +func replaceSingleAsteriskItalics(text string) string { + var b strings.Builder + b.Grow(len(text)) + + for i := 0; i < len(text); { + if text[i] != '*' || (i+1 < len(text) && text[i+1] == '*') { + b.WriteByte(text[i]) + i++ + continue + } + + closeIdx := -1 + for j := i + 1; j < len(text); j++ { + if text[j] == '\n' { + break + } + if text[j] != '*' { + continue + } + if j+1 < len(text) && text[j+1] == '*' { + continue + } + closeIdx = j + break + } + if closeIdx == -1 { + b.WriteByte(text[i]) + i++ + continue + } + + content := text[i+1 : closeIdx] + if strings.TrimSpace(content) == "" { + b.WriteByte(text[i]) + i++ + continue + } + + b.WriteString("") + b.WriteString(content) + b.WriteString("") + i = closeIdx + 1 + } + + return b.String() +} + // isBotMentioned checks if the bot is mentioned in the message via entities. func (c *TelegramChannel) isBotMentioned(message *telego.Message) bool { text, entities := telegramEntityTextAndList(message) @@ -782,3 +1040,75 @@ func (c *TelegramChannel) stripBotMention(content string) string { content = re.ReplaceAllString(content, "") return strings.TrimSpace(content) } + +func shouldReuseRecentAttachment(content string) bool { + trimmed := strings.ToLower(strings.TrimSpace(content)) + if trimmed == "" { + return false + } + + if trimmed == "to ro" || trimmed == "to romanian" || trimmed == "into romanian" { + return true + } + + if strings.Contains(trimmed, "romanian") || strings.Contains(trimmed, "to ro") { + return len(strings.Fields(trimmed)) <= 8 + } + + return false +} + +func wantsRomanianTranslation(content string) bool { + trimmed := strings.ToLower(strings.TrimSpace(content)) + if trimmed == "" { + return false + } + return strings.Contains(trimmed, "to ro") || strings.Contains(trimmed, "romanian") +} + +func wantsPDFReturn(content string) bool { + trimmed := strings.ToLower(strings.TrimSpace(content)) + if trimmed == "" { + return false + } + return strings.Contains(trimmed, "send back as pdf") || strings.Contains(trimmed, "as pdf") || strings.Contains(trimmed, "pdf export") +} + +func (c *TelegramChannel) rememberRecentMedia(chatID, mediaRef, mediaName string) { + if strings.TrimSpace(chatID) == "" || strings.TrimSpace(mediaRef) == "" { + return + } + + c.mediaMu.Lock() + defer c.mediaMu.Unlock() + + c.lastMediaRefByChat[chatID] = mediaRef + c.lastMediaNameByChat[chatID] = mediaName + c.lastMediaSeenByChat[chatID] = time.Now() +} + +func (c *TelegramChannel) recallRecentMedia(chatID string, ttl time.Duration) (string, string, bool) { + if strings.TrimSpace(chatID) == "" { + return "", "", false + } + + c.mediaMu.Lock() + defer c.mediaMu.Unlock() + + seenAt, ok := c.lastMediaSeenByChat[chatID] + if !ok { + return "", "", false + } + if ttl > 0 && time.Since(seenAt) > ttl { + delete(c.lastMediaRefByChat, chatID) + delete(c.lastMediaNameByChat, chatID) + delete(c.lastMediaSeenByChat, chatID) + return "", "", false + } + + ref := c.lastMediaRefByChat[chatID] + if strings.TrimSpace(ref) == "" { + return "", "", false + } + return ref, c.lastMediaNameByChat[chatID], true +} diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index 188a7c8fa..4c6bb41dd 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -444,5 +444,15 @@ func parseJID(s string) (types.JID, error) { if strings.Contains(s, "@") { return types.ParseJID(s) } - return types.NewJID(s, types.DefaultUserServer), nil + var digits strings.Builder + for _, r := range s { + if r >= '0' && r <= '9' { + digits.WriteRune(r) + } + } + clean := digits.String() + if clean == "" { + return types.JID{}, fmt.Errorf("invalid chat id %q", s) + } + return types.NewJID(clean, types.DefaultUserServer), nil } diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index aed6a1874..4360dcc32 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -12,6 +12,8 @@ func BuiltinDefinitions() []Definition { listCommand(), switchCommand(), checkCommand(), + runCommand(), + execCommand(), clearCommand(), } } diff --git a/pkg/commands/cmd_check.go b/pkg/commands/cmd_check.go index f0193dc4f..0bea0f1a9 100644 --- a/pkg/commands/cmd_check.go +++ b/pkg/commands/cmd_check.go @@ -3,12 +3,35 @@ package commands import ( "context" "fmt" + "os" + "slices" + "strings" ) func checkCommand() Definition { return Definition{ Name: "check", - Description: "Check channel availability", + Description: "Check channel availability and runtime health", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil { + return req.Reply(unavailableMsg) + } + + enabled := []string{} + if rt.GetEnabledChannels != nil { + enabled = rt.GetEnabledChannels() + } + shellEnabled := strings.EqualFold(strings.TrimSpace(os.Getenv("PICOCLAW_DASHBOARD_ALLOW_SHELL")), "true") + + msg := "Quick check:\n" + if req.Channel != "" { + msg += fmt.Sprintf("- channel: %s\n", req.Channel) + } + msg += fmt.Sprintf("- shell_exec: %t\n", shellEnabled) + msg += fmt.Sprintf("- enabled_channels: %s\n", strings.Join(enabled, ", ")) + msg += "- hints: /check channel | /check gws | /check mcp" + return req.Reply(strings.TrimSpace(msg)) + }, SubCommands: []SubCommand{ { Name: "channel", @@ -28,6 +51,54 @@ func checkCommand() Definition { return req.Reply(fmt.Sprintf("Channel '%s' is available and enabled", value)) }, }, + { + Name: "gws", + Description: "Check GWS CLI auth/status", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.ExecuteShell == nil { + return req.Reply(unavailableMsg) + } + out, err := rt.ExecuteShell(ctx, "gws auth status 2>&1 | sed -n 1,80p") + if err != nil { + if strings.TrimSpace(out) == "" { + return req.Reply("GWS check failed: " + err.Error()) + } + return req.Reply("GWS check output:\n" + out) + } + if strings.TrimSpace(out) == "" { + return req.Reply("GWS check returned no output") + } + return req.Reply("GWS status:\n" + out) + }, + }, + { + Name: "mcp", + Description: "Check MCP configuration status", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil || rt.Config == nil { + return req.Reply(unavailableMsg) + } + + mcpCfg := rt.Config.Tools.MCP + if !mcpCfg.Enabled { + return req.Reply("MCP is disabled in config") + } + if len(mcpCfg.Servers) == 0 { + return req.Reply("MCP is enabled but no servers are configured") + } + + servers := make([]string, 0, len(mcpCfg.Servers)) + for name, server := range mcpCfg.Servers { + state := "disabled" + if server.Enabled { + state = "enabled" + } + servers = append(servers, fmt.Sprintf("%s (%s)", name, state)) + } + slices.Sort(servers) + return req.Reply("MCP config:\n- servers: " + strings.Join(servers, ", ")) + }, + }, }, } } diff --git a/pkg/commands/cmd_clear.go b/pkg/commands/cmd_clear.go index f0951eb3b..925e5ace6 100644 --- a/pkg/commands/cmd_clear.go +++ b/pkg/commands/cmd_clear.go @@ -5,7 +5,7 @@ import "context" func clearCommand() Definition { return Definition{ Name: "clear", - Description: "Clear the chat history", + Description: "Clear chat history for this session", Usage: "/clear", Handler: func(_ context.Context, req Request, rt *Runtime) error { if rt == nil || rt.ClearHistory == nil { @@ -14,7 +14,7 @@ func clearCommand() Definition { if err := rt.ClearHistory(); err != nil { return req.Reply("Failed to clear chat history: " + err.Error()) } - return req.Reply("Chat history cleared!") + return req.Reply("Chat cleared. Let me know what you would like to do next.") }, } } diff --git a/pkg/commands/cmd_exec.go b/pkg/commands/cmd_exec.go new file mode 100644 index 000000000..0ff4a2dd9 --- /dev/null +++ b/pkg/commands/cmd_exec.go @@ -0,0 +1,14 @@ +package commands + +import "context" + +func execCommand() Definition { + return Definition{ + Name: "exec", + Description: "Alias for /run", + Usage: "/exec ", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + return executeShellCommand(ctx, req, rt, "/exec", "!exec") + }, + } +} diff --git a/pkg/commands/cmd_help.go b/pkg/commands/cmd_help.go index 94f7f0101..f00e12daf 100644 --- a/pkg/commands/cmd_help.go +++ b/pkg/commands/cmd_help.go @@ -28,7 +28,7 @@ func formatHelpMessage(defs []Definition) string { return "No commands available." } - lines := make([]string, 0, len(defs)) + lines := make([]string, 0, len(defs)+8) for _, def := range defs { usage := def.EffectiveUsage() if usage == "" { @@ -40,5 +40,14 @@ func formatHelpMessage(defs []Definition) string { } lines = append(lines, fmt.Sprintf("%s - %s", usage, desc)) } + + lines = append(lines, "") + lines = append(lines, "Quick Ops:") + lines = append(lines, "/switch openrouter - switch to OpenRouter free profile") + lines = append(lines, "/check gws - show Google Workspace auth status") + lines = append(lines, "/exec gws gmail +triage --max 5 --format table - list recent email via gws") + lines = append(lines, "/exec gws drive files list --params '{\"pageSize\":10}' --format table - list recent drive files") + lines = append(lines, "/exec gws calendar +agenda --days 3 --format table - list upcoming calendar events") + return strings.Join(lines, "\n") } diff --git a/pkg/commands/cmd_run.go b/pkg/commands/cmd_run.go new file mode 100644 index 000000000..4c9b21cb7 --- /dev/null +++ b/pkg/commands/cmd_run.go @@ -0,0 +1,47 @@ +package commands + +import ( + "context" + "strings" +) + +func runCommand() Definition { + return Definition{ + Name: "run", + Description: "Execute a shell command", + Usage: "/run ", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + return executeShellCommand(ctx, req, rt, "/run", "!run") + }, + } +} + +func executeShellCommand(ctx context.Context, req Request, rt *Runtime, prefixes ...string) error { + if rt == nil || rt.ExecuteShell == nil { + return req.Reply(unavailableMsg) + } + cmd := strings.TrimSpace(req.Text) + for _, p := range prefixes { + if strings.HasPrefix(cmd, p) { + cmd = strings.TrimSpace(strings.TrimPrefix(cmd, p)) + break + } + } + if cmd == "" { + if len(prefixes) > 0 { + return req.Reply("Usage: " + strings.TrimPrefix(prefixes[0], "!") + " ") + } + return req.Reply("Usage: /run ") + } + out, err := rt.ExecuteShell(ctx, cmd) + if err != nil { + if out != "" { + return req.Reply("Command failed:\n" + out) + } + return req.Reply("Command failed: " + err.Error()) + } + if strings.TrimSpace(out) == "" { + return req.Reply("Done.") + } + return req.Reply(out) +} diff --git a/pkg/commands/cmd_show.go b/pkg/commands/cmd_show.go index c655e6880..efad9867c 100644 --- a/pkg/commands/cmd_show.go +++ b/pkg/commands/cmd_show.go @@ -3,36 +3,82 @@ package commands import ( "context" "fmt" + "strings" ) 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) - } + Usage: "/show [model|channel|agents]", + Handler: func(ctx context.Context, req Request, rt *Runtime) error { + sub := normalizeCommandName(nthToken(req.Text, 1)) + switch sub { + case "", "all": + modelLine := "Current Model: unavailable" + if rt != nil && rt.GetModelInfo != nil { name, provider := rt.GetModelInfo() - return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider)) - }, - }, - { - Name: "channel", - Description: "Current channel", - 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(), - }, + if provider == "" { + provider = "configured default" + } + modelLine = fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider) + } + + channelLine := fmt.Sprintf("Current Channel: %s", req.Channel) + + agentsLine := "Registered Agents: unavailable" + if rt != nil && rt.ListAgentIDs != nil { + ids := rt.ListAgentIDs() + if len(ids) == 0 { + agentsLine = "Registered Agents: none" + } else { + agentsLine = fmt.Sprintf("Registered Agents: %s", strings.Join(ids, ", ")) + } + } + + return req.Reply(strings.Join([]string{modelLine, channelLine, agentsLine}, "\n")) + case "model": + if rt == nil || rt.GetModelInfo == nil { + return req.Reply(unavailableMsg) + } + name, provider := rt.GetModelInfo() + if provider == "" { + provider = "configured default" + } + return req.Reply(fmt.Sprintf("Current Model: %s (Provider: %s)", name, provider)) + case "channel": + return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel)) + case "agents": + return agentsHandler()(ctx, req, rt) + case "preview", "previews": + if rt == nil || rt.GetRecentPreviews == nil { + return req.Reply(unavailableMsg) + } + items := rt.GetRecentPreviews() + if len(items) == 0 { + return req.Reply("No recent previews recorded.") + } + var lines []string + for i, item := range items { + if i >= 5 { + break + } + label := item.Slug + if strings.TrimSpace(label) == "" { + label = "preview" + } + lines = append(lines, label) + if strings.TrimSpace(item.TailscaleURL) != "" { + lines = append(lines, "• Tailscale: "+item.TailscaleURL) + } + if strings.TrimSpace(item.LocalURL) != "" { + lines = append(lines, "• Local: "+item.LocalURL) + } + } + return req.Reply(strings.Join(lines, "\n")) + default: + return req.Reply("Usage: /show [model|channel|agents]") + } }, } } diff --git a/pkg/commands/cmd_switch.go b/pkg/commands/cmd_switch.go index fb8fc109e..7254001b6 100644 --- a/pkg/commands/cmd_switch.go +++ b/pkg/commands/cmd_switch.go @@ -3,40 +3,64 @@ package commands import ( "context" "fmt" + "strings" ) func switchCommand() Definition { return Definition{ Name: "switch", Description: "Switch model", - SubCommands: []SubCommand{ - { - Name: "model", - Description: "Switch to a different model", - ArgsUsage: "to ", - Handler: func(_ context.Context, req Request, rt *Runtime) error { - if rt == nil || rt.SwitchModel == nil { - return req.Reply(unavailableMsg) - } - // Parse: /switch model to - value := nthToken(req.Text, 3) // tokens: [/switch, model, to, ] - if nthToken(req.Text, 2) != "to" || value == "" { - return req.Reply("Usage: /switch model to ") - } - 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", - Handler: func(_ context.Context, req Request, _ *Runtime) error { - return req.Reply("This command has moved. Please use: /check channel ") - }, - }, + Usage: "/switch [model to |channel]", + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil { + return req.Reply(unavailableMsg) + } + arg1 := normalizeCommandName(nthToken(req.Text, 1)) + if arg1 == "" { + return req.Reply("Usage: /switch [model to |channel]") + } + + if arg1 == "channel" { + return req.Reply("This command has moved. Please use: /check channel ") + } + + if rt.SwitchModel == nil { + return req.Reply(unavailableMsg) + } + + var value string + if arg1 == "model" { + if normalizeCommandName(nthToken(req.Text, 2)) != "to" { + return req.Reply("Usage: /switch model to ") + } + value = nthToken(req.Text, 3) + } else { + // Convenience form: /switch + value = nthToken(req.Text, 1) + } + + value = normalizeSwitchModelValue(value) + if value == "" { + return req.Reply("Usage: /switch model to ") + } + 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)) }, } } + +func normalizeSwitchModelValue(v string) string { + value := strings.TrimSpace(v) + if value == "" { + return "" + } + switch strings.ToLower(value) { + case "openrouter", "openrouter/free", "free": + return "openrouter-free" + default: + return value + } +} diff --git a/pkg/commands/executor.go b/pkg/commands/executor.go index 78a50e6c2..83b5ebc9b 100644 --- a/pkg/commands/executor.go +++ b/pkg/commands/executor.go @@ -68,6 +68,10 @@ func (e *Executor) executeDefinition(ctx context.Context, req Request, def Defin // Sub-command routing subName := nthToken(req.Text, 1) if subName == "" { + if def.Handler != nil { + err := def.Handler(ctx, req, e.rt) + return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} + } err := req.Reply("Usage: " + def.EffectiveUsage()) return ExecuteResult{Outcome: OutcomeHandled, Command: def.Name, Err: err} } diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 037184686..64d2162de 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -1,10 +1,22 @@ package commands -import "github.com/sipeed/picoclaw/pkg/config" +import ( + "context" + + "github.com/sipeed/picoclaw/pkg/config" +) // Runtime provides runtime dependencies to command handlers. It is constructed // per-request by the agent loop so that per-request state (like session scope) // can coexist with long-lived callbacks (like GetModelInfo). +type PreviewInfo struct { + Slug string + LocalURL string + TailscaleURL string + Root string + Entry string +} + type Runtime struct { Config *config.Config GetModelInfo func() (name, provider string) @@ -13,5 +25,7 @@ type Runtime struct { GetEnabledChannels func() []string SwitchModel func(value string) (oldModel string, err error) SwitchChannel func(value string) error + ExecuteShell func(ctx context.Context, command string) (string, error) + GetRecentPreviews func() []PreviewInfo ClearHistory func() error } diff --git a/pkg/config/config.go b/pkg/config/config.go index deff1eb0f..10c4f4c81 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -267,6 +267,7 @@ type TelegramConfig struct { BaseURL string `json:"base_url" env:"PICOCLAW_CHANNELS_TELEGRAM_BASE_URL"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` + AdminIDs FlexibleStringSlice `json:"admin_ids,omitempty" env:"PICOCLAW_CHANNELS_TELEGRAM_ADMIN_IDS"` GroupTrigger GroupTriggerConfig `json:"group_trigger,omitempty"` Typing TypingConfig `json:"typing,omitempty"` Placeholder PlaceholderConfig `json:"placeholder,omitempty"` diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 0e8db7409..f9a939b71 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "net/url" + "regexp" "strings" "time" @@ -102,6 +103,71 @@ func NewProviderWithMaxTokensFieldAndTimeout( ) } +var simpleToolArgPattern = regexp.MustCompile(`"([A-Za-z0-9_]+)"\s*:\s*"((?:\\.|[^"])*)`) + +func salvageToolCallArguments(raw string) map[string]any { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + + var parsed map[string]any + if err := json.Unmarshal([]byte(raw), &parsed); err == nil && parsed != nil { + return parsed + } + + for start := len(raw) - 1; start >= 0; start-- { + if raw[start] != '{' { + continue + } + for end := len(raw); end > start; end-- { + if raw[end-1] != '}' { + continue + } + candidate := strings.TrimSpace(raw[start:end]) + if candidate == "" { + continue + } + parsed = nil + if err := json.Unmarshal([]byte(candidate), &parsed); err == nil && parsed != nil { + return parsed + } + } + } + + if salvaged := salvageLooseStringArguments(raw); len(salvaged) > 0 { + return salvaged + } + return nil +} + +func salvageLooseStringArguments(raw string) map[string]any { + allowed := map[string]bool{ + "path": true, "entry": true, "slug": true, "filename": true, + "content": true, "channel": true, "chat_id": true, + "url": true, "query": true, "command": true, + "oldpath": true, "newpath": true, "subject": true, + "body": true, "to": true, + } + out := map[string]any{} + for _, match := range simpleToolArgPattern.FindAllStringSubmatch(raw, -1) { + if len(match) < 3 || !allowed[match[1]] { + continue + } + value := match[2] + var decoded string + if err := json.Unmarshal([]byte("\""+value+"\""), &decoded); err == nil { + out[match[1]] = strings.TrimSpace(decoded) + continue + } + out[match[1]] = strings.TrimSpace(strings.ReplaceAll(value, `\"`, `"`)) + } + if len(out) == 0 { + return nil + } + return out +} + func (p *Provider) Chat( ctx context.Context, messages []Message, @@ -326,7 +392,12 @@ func parseResponse(body io.Reader) (*LLMResponse, error) { if tc.Function.Arguments != "" { if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) - arguments["raw"] = tc.Function.Arguments + if salvaged := salvageToolCallArguments(tc.Function.Arguments); salvaged != nil { + arguments = salvaged + log.Printf("openai_compat: salvaged malformed tool call arguments for %q", name) + } else { + arguments["raw"] = tc.Function.Arguments + } } } } @@ -439,7 +510,7 @@ func normalizeModel(model, apiBase string) string { prefix := strings.ToLower(before) switch prefix { - case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", + case "litellm", "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "gemini", "openrouter", "zhipu", "mistral", "vivgrid", "minimax": return after default: diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 9a3a7acc5..814261f25 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -15,6 +15,13 @@ import ( "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) +func TestNormalizeModel_StripsGeminiPrefixForGeminiAPI(t *testing.T) { + got := normalizeModel("gemini/gemini-2.5-flash", "https://generativelanguage.googleapis.com/v1beta") + if got != "gemini-2.5-flash" { + t.Fatalf("normalizeModel() = %q, want %q", got, "gemini-2.5-flash") + } +} + func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { var requestBody map[string]any @@ -108,6 +115,49 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) { } } +func TestProviderChat_SalvagesMalformedToolCallArguments(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{ + "content": "", + "tool_calls": []map[string]any{ + { + "id": "call_1", + "type": "function", + "function": map[string]any{ + "name": "read_file", + "arguments": "{\"path\": \"/\"tmp/picoclaw_media/photo.jpgtmp/picoclaw_media/photo.jpg\"}{\"path\": \"/tmp/picoclaw_media/photo.jpg\"}", + }, + }, + }, + }, + "finish_reason": "tool_calls", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "read img"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].Arguments["path"] != "/tmp/picoclaw_media/photo.jpg" { + t.Fatalf("ToolCalls[0].Arguments[path] = %v, want /tmp/picoclaw_media/photo.jpg", out.ToolCalls[0].Arguments["path"]) + } + if _, ok := out.ToolCalls[0].Arguments["raw"]; ok { + t.Fatalf("unexpected raw field after salvage: %v", out.ToolCalls[0].Arguments["raw"]) + } +} + func TestProviderChat_ParsesReasoningContent(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { resp := map[string]any{ diff --git a/pkg/skills/install_check.go b/pkg/skills/install_check.go new file mode 100644 index 000000000..77e5652d4 --- /dev/null +++ b/pkg/skills/install_check.go @@ -0,0 +1,149 @@ +package skills + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +var trustedClawHubHosts = map[string]struct{}{ + "clawhub.ai": {}, + "www.clawhub.ai": {}, +} + +type InstallCheckReport struct { + Version int `json:"version"` + CheckedAt string `json:"checked_at"` + Registry string `json:"registry"` + Slug string `json:"slug"` + InstalledVersion string `json:"installed_version"` + TrustedRegistry bool `json:"trusted_registry"` + HasSkillMD bool `json:"has_skill_md"` + FileCount int `json:"file_count"` + ExecutableFiles []string `json:"executable_files,omitempty"` + BinaryFiles []string `json:"binary_files,omitempty"` + Symlinks []string `json:"symlinks,omitempty"` + SHA256 map[string]string `json:"sha256"` + Passed bool `json:"passed"` + FailureReasons []string `json:"failure_reasons,omitempty"` +} + +func ValidateTrustedClawHubConfig(cfg ClawHubConfig) error { + baseURL := strings.TrimSpace(cfg.BaseURL) + if baseURL == "" { + baseURL = "https://clawhub.ai" + } + u, err := url.Parse(baseURL) + if err != nil { + return fmt.Errorf("invalid base URL: %w", err) + } + if !strings.EqualFold(u.Scheme, "https") { + return fmt.Errorf("registry must use https") + } + host := strings.ToLower(u.Hostname()) + if _, ok := trustedClawHubHosts[host]; !ok { + return fmt.Errorf("registry host %q is not trusted", host) + } + return nil +} + +func VerifyInstalledSkill(targetDir, registryName, slug, version string) (*InstallCheckReport, error) { + report := &InstallCheckReport{ + Version: 1, + CheckedAt: time.Now().Format(time.RFC3339), + Registry: registryName, + Slug: slug, + InstalledVersion: version, + TrustedRegistry: registryName == "clawhub", + SHA256: map[string]string{}, + } + + err := filepath.WalkDir(targetDir, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + rel, err := filepath.Rel(targetDir, path) + if err != nil { + return err + } + if rel == "." { + return nil + } + if d.Type()&os.ModeSymlink != 0 { + report.Symlinks = append(report.Symlinks, filepath.ToSlash(rel)) + return nil + } + if d.IsDir() { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + report.FileCount++ + rel = filepath.ToSlash(rel) + if rel == "SKILL.md" { + report.HasSkillMD = true + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + sum := sha256.Sum256(data) + report.SHA256[rel] = hex.EncodeToString(sum[:]) + if info.Mode()&0o111 != 0 { + report.ExecutableFiles = append(report.ExecutableFiles, rel) + } + sample := data + if len(sample) > 4096 { + sample = sample[:4096] + } + if strings.IndexByte(string(sample), 0) >= 0 { + report.BinaryFiles = append(report.BinaryFiles, rel) + } + return nil + }) + if err != nil { + return nil, err + } + + sort.Strings(report.ExecutableFiles) + sort.Strings(report.BinaryFiles) + sort.Strings(report.Symlinks) + + if !report.TrustedRegistry { + report.FailureReasons = append(report.FailureReasons, "registry is not trusted") + } + if !report.HasSkillMD { + report.FailureReasons = append(report.FailureReasons, "missing top-level SKILL.md") + } + if len(report.Symlinks) > 0 { + report.FailureReasons = append(report.FailureReasons, "skill contains symlinks") + } + if len(report.ExecutableFiles) > 0 { + report.FailureReasons = append(report.FailureReasons, "skill contains executable files") + } + if len(report.BinaryFiles) > 0 { + report.FailureReasons = append(report.FailureReasons, "skill contains binary files") + } + report.Passed = len(report.FailureReasons) == 0 + + if data, err := json.MarshalIndent(report, "", " "); err == nil { + if writeErr := fileutil.WriteFileAtomic(filepath.Join(targetDir, ".skill-check.json"), data, 0o600); writeErr != nil { + return nil, writeErr + } + } else { + return nil, err + } + + return report, nil +} diff --git a/pkg/skills/registry.go b/pkg/skills/registry.go index 45ae72253..27e148165 100644 --- a/pkg/skills/registry.go +++ b/pkg/skills/registry.go @@ -101,7 +101,11 @@ func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager { rm.maxConcurrent = cfg.MaxConcurrentSearches } if cfg.ClawHub.Enabled { - rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub)) + if err := ValidateTrustedClawHubConfig(cfg.ClawHub); err != nil { + slog.Warn("skills registry disabled: clawhub config is not trusted", "error", err) + } else { + rm.AddRegistry(NewClawHubRegistry(cfg.ClawHub)) + } } return rm } diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 6af0aa9e1..cbfa538da 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -205,14 +205,14 @@ func (t *CronTool) addJob(ctx context.Context, args map[string]any) *ToolResult t.cronService.UpdateJob(job) } - return SilentResult(fmt.Sprintf("Cron job added: %s (id: %s)", job.Name, job.ID)) + return UserResult(fmt.Sprintf("Reminder scheduled: %s (id: %s)", job.Name, job.ID)) } func (t *CronTool) listJobs() *ToolResult { jobs := t.cronService.ListJobs(false) if len(jobs) == 0 { - return SilentResult("No scheduled jobs") + return UserResult("No scheduled jobs") } var result strings.Builder @@ -231,7 +231,7 @@ func (t *CronTool) listJobs() *ToolResult { result.WriteString(fmt.Sprintf("- %s (id: %s, %s)\n", j.Name, j.ID, scheduleInfo)) } - return SilentResult(result.String()) + return UserResult(result.String()) } func (t *CronTool) removeJob(args map[string]any) *ToolResult { @@ -241,7 +241,7 @@ func (t *CronTool) removeJob(args map[string]any) *ToolResult { } if t.cronService.RemoveJob(jobID) { - return SilentResult(fmt.Sprintf("Cron job removed: %s", jobID)) + return UserResult(fmt.Sprintf("Scheduled job removed: %s", jobID)) } return ErrorResult(fmt.Sprintf("Job %s not found", jobID)) } @@ -261,7 +261,7 @@ func (t *CronTool) enableJob(args map[string]any, enable bool) *ToolResult { if !enable { status = "disabled" } - return SilentResult(fmt.Sprintf("Cron job '%s' %s", job.Name, status)) + return UserResult(fmt.Sprintf("Scheduled job '%s' %s", job.Name, status)) } // ExecuteJob executes a cron job through the agent diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index cd8da3195..033be8229 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -1,12 +1,15 @@ package tools import ( + "bytes" "context" "fmt" "io/fs" "os" + "os/exec" "path/filepath" "regexp" + "sort" "strings" "time" @@ -127,9 +130,220 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe if err != nil { return ErrorResult(err.Error()) } + if looksLikePDF(path, content) { + extracted, method, extractErr := extractPDFText(ctx, path) + if extractErr != nil { + return ErrorResult(fmt.Sprintf("failed to extract PDF text: %v", extractErr)) + } + if strings.TrimSpace(extracted) != "" { + return NewToolResult(fmt.Sprintf("[pdf text extracted via %s]\n%s", method, extracted)) + } + return ErrorResult("failed to extract PDF text: empty result") + } return NewToolResult(string(content)) } +func looksLikePDF(path string, content []byte) bool { + if strings.EqualFold(filepath.Ext(strings.TrimSpace(path)), ".pdf") { + return true + } + return bytes.HasPrefix(content, []byte("%PDF-")) +} + +func extractPDFText(ctx context.Context, path string) (string, string, error) { + if text, err := extractPDFTextWithPoppler(ctx, path); err == nil && isUsefulPDFText(text) { + return text, "pdftotext", nil + } + + if text, err := extractPDFTextWithOCR(ctx, path); err == nil && isUsefulPDFText(text) { + return text, "ocr", nil + } + + if _, err := exec.LookPath("pdftotext"); err != nil { + return "", "", fmt.Errorf("pdftotext not available; install poppler-utils") + } + if _, err := exec.LookPath("pdftoppm"); err != nil { + return "", "", fmt.Errorf("pdftoppm not available; install poppler-utils") + } + if _, err := exec.LookPath("tesseract"); err != nil { + return "", "", fmt.Errorf("tesseract not available") + } + return "", "", fmt.Errorf("no usable text could be extracted from the PDF") +} + +func extractPDFTextWithPoppler(ctx context.Context, path string) (string, error) { + if _, err := exec.LookPath("pdftotext"); err != nil { + return "", err + } + + tmpFile, err := os.CreateTemp("", "picoclaw-pdf-*.txt") + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + _ = tmpFile.Close() + defer os.Remove(tmpPath) + + cmd := exec.CommandContext(ctx, "pdftotext", "-layout", "-nopgbrk", path, tmpPath) + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("pdftotext failed: %v: %s", err, strings.TrimSpace(string(out))) + } + data, err := os.ReadFile(tmpPath) + if err != nil { + return "", err + } + return strings.TrimSpace(string(data)), nil +} + +func extractPDFTextWithOCR(ctx context.Context, path string) (string, error) { + if _, err := exec.LookPath("pdftoppm"); err != nil { + return "", err + } + if _, err := exec.LookPath("tesseract"); err != nil { + return "", err + } + + tmpDir, err := os.MkdirTemp("", "picoclaw-pdf-ocr-*") + if err != nil { + return "", err + } + defer os.RemoveAll(tmpDir) + + prefix := filepath.Join(tmpDir, "page") + cmd := exec.CommandContext(ctx, "pdftoppm", "-png", path, prefix) + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("pdftoppm failed: %v: %s", err, strings.TrimSpace(string(out))) + } + + pages, err := filepath.Glob(prefix + "-*.png") + if err != nil { + return "", err + } + if len(pages) == 0 { + return "", fmt.Errorf("no pages rendered for OCR") + } + sort.Strings(pages) + + langs := preferredTesseractOCRLanguages() + var parts []string + for _, page := range pages { + args := []string{page, "stdout", "--psm", "6"} + if langs != "" { + args = append(args, "-l", langs) + } + cmd := exec.CommandContext(ctx, "tesseract", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("tesseract failed on %s: %v: %s", filepath.Base(page), err, strings.TrimSpace(string(out))) + } + text := strings.TrimSpace(string(out)) + if text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n\n"), nil +} + +func preferredTesseractOCRLanguages() string { + override := strings.TrimSpace(os.Getenv("PICO_TESSERACT_LANGS")) + if override != "" { + return normalizeTesseractLangSpec(override) + } + + available := installedTesseractLanguages() + if len(available) == 0 { + return "" + } + + preferred := []string{"eng", "nld", "ron", "spa", "fra", "deu", "ita", "por", "pol", "tur"} + seen := map[string]bool{} + var picks []string + for _, lang := range preferred { + if containsString(available, lang) && !seen[lang] { + seen[lang] = true + picks = append(picks, lang) + } + } + if len(picks) > 0 { + return strings.Join(picks, "+") + } + for _, lang := range available { + if lang == "" || lang == "osd" { + continue + } + return lang + } + return "" +} + +func installedTesseractLanguages() []string { + cmd := exec.Command("tesseract", "--list-langs") + out, err := cmd.Output() + if err != nil { + return nil + } + var langs []string + seen := map[string]bool{} + for _, line := range strings.Split(string(out), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "List of available languages") || line == "osd" { + continue + } + if !seen[line] { + seen[line] = true + langs = append(langs, line) + } + } + return langs +} + +func normalizeTesseractLangSpec(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + raw = strings.NewReplacer(",", "+", " ", "+", ";", "+").Replace(raw) + parts := strings.Split(raw, "+") + seen := map[string]bool{} + var cleaned []string + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" || part == "osd" || seen[part] { + continue + } + seen[part] = true + cleaned = append(cleaned, part) + } + return strings.Join(cleaned, "+") +} + +func containsString(items []string, needle string) bool { + for _, item := range items { + if item == needle { + return true + } + } + return false +} + +func isUsefulPDFText(text string) bool { + trimmed := strings.TrimSpace(text) + if len(trimmed) < 20 { + return false + } + words := len(strings.Fields(trimmed)) + if words >= 8 { + return true + } + letters := 0 + for _, r := range trimmed { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { + letters++ + } + } + return letters >= 20 +} + type WriteFileTool struct { fs fileSystem } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 666004cd4..817bc4d57 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -2,6 +2,7 @@ package tools import ( "context" + "fmt" "io" "os" "path/filepath" @@ -520,3 +521,55 @@ func TestWhitelistFs_AllowsMatchingPaths(t *testing.T) { t.Errorf("expected non-whitelisted path to be blocked, got: %s", result.ForLLM) } } + +func TestFilesystemTool_ReadFile_PDFExtraction(t *testing.T) { + if _, err := os.Stat("/usr/bin/pdftotext"); err != nil { + t.Skip("pdftotext not installed") + } + + tmpDir := t.TempDir() + pdfPath := filepath.Join(tmpDir, "sample.pdf") + if err := writeMinimalTextPDF(pdfPath, "Hello PDF extraction test"); err != nil { + t.Fatalf("writeMinimalTextPDF: %v", err) + } + + tool := NewReadFileTool("", false) + result := tool.Execute(context.Background(), map[string]any{"path": pdfPath}) + if result.IsError { + t.Fatalf("expected successful PDF extraction, got error: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "pdf text extracted via") { + t.Fatalf("expected extraction marker, got: %s", result.ForLLM) + } + if !strings.Contains(strings.ToLower(result.ForLLM), "hello pdf extraction test") { + t.Fatalf("expected extracted text, got: %s", result.ForLLM) + } +} + +func writeMinimalTextPDF(path, text string) error { + escaped := strings.NewReplacer(`\`, `\\`, `(`, `\(`, `)`, `\)`).Replace(text) + stream := fmt.Sprintf("BT /F1 24 Tf 72 720 Td (%s) Tj ET", escaped) + objects := []string{ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>", + fmt.Sprintf("<< /Length %d >>\nstream\n%s\nendstream", len(stream), stream), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>", + } + + var b strings.Builder + b.WriteString("%PDF-1.4\n") + offsets := make([]int, len(objects)+1) + for i, obj := range objects { + offsets[i+1] = b.Len() + fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", i+1, obj) + } + xref := b.Len() + fmt.Fprintf(&b, "xref\n0 %d\n", len(objects)+1) + b.WriteString("0000000000 65535 f \n") + for i := 1; i <= len(objects); i++ { + fmt.Fprintf(&b, "%010d 00000 n \n", offsets[i]) + } + fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objects)+1, xref) + return os.WriteFile(path, []byte(b.String()), 0o644) +} diff --git a/pkg/tools/message.go b/pkg/tools/message.go index 438ceeddd..0661299b5 100644 --- a/pkg/tools/message.go +++ b/pkg/tools/message.go @@ -9,8 +9,9 @@ import ( type SendCallback func(channel, chatID, content string) error type MessageTool struct { - sendCallback SendCallback - sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round + sendCallback SendCallback + sentInRound atomic.Bool // Tracks whether a message was sent in the current processing round + sentToCurrentRound atomic.Bool // Tracks whether a message was sent back to the current chat in this round } func NewMessageTool() *MessageTool { @@ -50,6 +51,7 @@ func (t *MessageTool) Parameters() map[string]any { // Called by the agent loop at the start of each inbound message processing round. func (t *MessageTool) ResetSentInRound() { t.sentInRound.Store(false) + t.sentToCurrentRound.Store(false) } // HasSentInRound returns true if the message tool sent a message during the current round. @@ -57,6 +59,10 @@ func (t *MessageTool) HasSentInRound() bool { return t.sentInRound.Load() } +func (t *MessageTool) HasSentToCurrentRound() bool { + return t.sentToCurrentRound.Load() +} + func (t *MessageTool) SetSendCallback(callback SendCallback) { t.sendCallback = callback } @@ -69,12 +75,14 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes channel, _ := args["channel"].(string) chatID, _ := args["chat_id"].(string) + currentChannel := ToolChannel(ctx) + currentChatID := ToolChatID(ctx) if channel == "" { - channel = ToolChannel(ctx) + channel = currentChannel } if chatID == "" { - chatID = ToolChatID(ctx) + chatID = currentChatID } if channel == "" || chatID == "" { @@ -94,6 +102,9 @@ func (t *MessageTool) Execute(ctx context.Context, args map[string]any) *ToolRes } t.sentInRound.Store(true) + if channel == currentChannel && chatID == currentChatID { + t.sentToCurrentRound.Store(true) + } // Silent: user already received the message directly return &ToolResult{ ForLLM: fmt.Sprintf("Message sent to %s:%s", channel, chatID), diff --git a/pkg/tools/preview.go b/pkg/tools/preview.go new file mode 100644 index 000000000..8c6cb38dd --- /dev/null +++ b/pkg/tools/preview.go @@ -0,0 +1,196 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// HostedPreview describes a published local preview. +type HostedPreview struct { + Slug string `json:"slug,omitempty"` + Root string `json:"root,omitempty"` + Entry string `json:"entry,omitempty"` + LocalURL string `json:"local_url,omitempty"` + TailscaleURL string `json:"tailscale_url,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +// PreviewPublisher publishes a local directory on a preview server and returns URLs. +type PreviewPublisher func(root, entry, slug string) (*HostedPreview, error) + +// HostPreviewTool publishes a local site/app directory and returns preview URLs. +type HostPreviewTool struct { + workspace string + restrict bool + publish PreviewPublisher +} + +const recentPreviewsStatePath = "state/recent_previews.json" + +func NewHostPreviewTool(workspace string, restrict bool, publish PreviewPublisher) *HostPreviewTool { + return &HostPreviewTool{workspace: workspace, restrict: restrict, publish: publish} +} + +func (t *HostPreviewTool) Name() string { + return "host_preview" +} + +func (t *HostPreviewTool) Description() string { + return "Host a local site/app directory on the built-in preview server and return Tailscale/local URLs for review." +} + +func (t *HostPreviewTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to a built site/app directory, or a single entry file such as index.html.", + }, + "entry": map[string]any{ + "type": "string", + "description": "Optional entry file relative to the hosted directory. Defaults to index.html when present, or the file basename when path points to a file.", + }, + "slug": map[string]any{ + "type": "string", + "description": "Optional stable preview slug. Reuse the same slug to keep updating one preview URL across edits.", + }, + }, + "required": []string{"path"}, + } +} + +func (t *HostPreviewTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + _ = ctx + if t.publish == nil { + return ErrorResult("preview publishing is not configured") + } + + rawPath, _ := args["path"].(string) + if strings.TrimSpace(rawPath) == "" { + return ErrorResult("path is required") + } + + resolved, err := validatePath(rawPath, t.workspace, t.restrict) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid path: %v", err)) + } + + info, err := os.Stat(resolved) + if err != nil { + return ErrorResult(fmt.Sprintf("path not found: %v", err)) + } + + entry, _ := args["entry"].(string) + entry = strings.TrimSpace(entry) + slug, _ := args["slug"].(string) + slug = strings.TrimSpace(slug) + + root := resolved + if info.IsDir() { + if entry != "" { + candidate := filepath.Join(root, filepath.FromSlash(strings.TrimLeft(entry, "/"))) + entryInfo, statErr := os.Stat(candidate) + if statErr != nil { + return ErrorResult(fmt.Sprintf("entry not found: %v", statErr)) + } + if entryInfo.IsDir() { + entry = filepath.ToSlash(filepath.Join(entry, "index.html")) + } + } else if _, statErr := os.Stat(filepath.Join(root, "index.html")); statErr == nil { + entry = "index.html" + } + } else { + root = filepath.Dir(resolved) + if entry == "" { + entry = filepath.Base(resolved) + } + } + + preview, err := t.publish(root, entry, slug) + if err != nil { + return ErrorResult(err.Error()) + } + if preview != nil { + preview.UpdatedAt = time.Now().Format(time.RFC3339) + if strings.TrimSpace(preview.Slug) == "" { + preview.Slug = slug + } + if saveErr := SaveRecentPreview(t.workspace, preview); saveErr != nil { + return ErrorResult(fmt.Sprintf("preview published but failed to save state: %v", saveErr)) + } + } + + lines := []string{fmt.Sprintf("Preview hosted from %s", preview.Root)} + if preview.Entry != "" { + lines = append(lines, "Entry: "+preview.Entry) + } + if preview.TailscaleURL != "" { + lines = append(lines, "Tailscale URL: "+preview.TailscaleURL) + } + if preview.LocalURL != "" { + lines = append(lines, "Local URL: "+preview.LocalURL) + } + if preview.TailscaleURL == "" && preview.LocalURL == "" { + return ErrorResult("preview published but no URL was generated") + } + + content := strings.Join(lines, "\n") + return &ToolResult{ForLLM: content, ForUser: content} +} + +func recentPreviewsFile(workspace string) string { + return filepath.Join(workspace, recentPreviewsStatePath) +} + +func LoadRecentPreviews(workspace string) ([]HostedPreview, error) { + path := recentPreviewsFile(workspace) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + var previews []HostedPreview + if err := json.Unmarshal(data, &previews); err != nil { + return nil, err + } + return previews, nil +} + +func SaveRecentPreview(workspace string, preview *HostedPreview) error { + if preview == nil { + return nil + } + path := recentPreviewsFile(workspace) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + previews, err := LoadRecentPreviews(workspace) + if err != nil { + return err + } + updated := make([]HostedPreview, 0, len(previews)+1) + updated = append(updated, *preview) + for _, item := range previews { + if strings.TrimSpace(item.Slug) == strings.TrimSpace(preview.Slug) { + continue + } + updated = append(updated, item) + if len(updated) >= 8 { + break + } + } + data, err := json.MarshalIndent(updated, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + return os.WriteFile(path, data, 0o644) +} diff --git a/pkg/tools/preview_test.go b/pkg/tools/preview_test.go new file mode 100644 index 000000000..534c6d00c --- /dev/null +++ b/pkg/tools/preview_test.go @@ -0,0 +1,61 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestHostPreviewToolUsesFileEntryByDefault(t *testing.T) { + workspace := t.TempDir() + file := filepath.Join(workspace, "index.html") + if err := os.WriteFile(file, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + var gotRoot, gotEntry, gotSlug string + tool := NewHostPreviewTool(workspace, true, func(root, entry, slug string) (*HostedPreview, error) { + gotRoot, gotEntry, gotSlug = root, entry, slug + return &HostedPreview{Root: root, Entry: entry, LocalURL: "http://127.0.0.1:3002/preview/test/index.html"}, nil + }) + + result := tool.Execute(context.Background(), map[string]any{"path": file, "slug": "test"}) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if gotRoot != workspace { + t.Fatalf("root mismatch: got %q want %q", gotRoot, workspace) + } + if gotEntry != "index.html" { + t.Fatalf("entry mismatch: got %q", gotEntry) + } + if gotSlug != "test" { + t.Fatalf("slug mismatch: got %q", gotSlug) + } +} + +func TestHostPreviewToolUsesIndexForDirectory(t *testing.T) { + workspace := t.TempDir() + project := filepath.Join(workspace, "site") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(project, "index.html"), []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + var gotEntry string + tool := NewHostPreviewTool(workspace, true, func(root, entry, slug string) (*HostedPreview, error) { + gotEntry = entry + return &HostedPreview{Root: root, Entry: entry, LocalURL: "http://127.0.0.1:3002/preview/test/"}, nil + }) + + result := tool.Execute(context.Background(), map[string]any{"path": project}) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if gotEntry != "index.html" { + t.Fatalf("entry mismatch: got %q", gotEntry) + } +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index b8a811d03..9ab2cefd4 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -91,6 +91,11 @@ var ( "/dev/stdout": true, "/dev/stderr": true, } + + gwsGmailListPattern = regexp.MustCompile(`(?i)\bgws\s+gmail\s+(?:\+?list)\b`) + gwsGmailReadPattern = regexp.MustCompile(`(?i)\bgws\s+gmail\s+\+read\b`) + gwsIDFlagPattern = regexp.MustCompile(`(?i)--id\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))`) + gwsFormatPattern = regexp.MustCompile(`(?i)--format\s+([a-z]+)`) ) func NewExecTool(workingDir string, restrict bool) (*ExecTool, error) { @@ -201,11 +206,13 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult return ErrorResult(guardError) } + command, effectiveTimeout := normalizeCommandForExecution(command, t.timeout) + // timeout == 0 means no timeout var cmdCtx context.Context var cancel context.CancelFunc - if t.timeout > 0 { - cmdCtx, cancel = context.WithTimeout(ctx, t.timeout) + if effectiveTimeout > 0 { + cmdCtx, cancel = context.WithTimeout(ctx, effectiveTimeout) } else { cmdCtx, cancel = context.WithCancel(ctx) } @@ -258,7 +265,7 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult if err != nil { if errors.Is(cmdCtx.Err(), context.DeadlineExceeded) { - msg := fmt.Sprintf("Command timed out after %v", t.timeout) + msg := fmt.Sprintf("Command timed out after %v", effectiveTimeout) return &ToolResult{ ForLLM: msg, ForUser: msg, @@ -292,6 +299,75 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]any) *ToolResult } } +func normalizeCommandForExecution(command string, timeout time.Duration) (string, time.Duration) { + if runtime.GOOS == "windows" { + return command, timeout + } + + command, timeout = normalizeKnownGWSCommandAliases(command, timeout) + + trimmed := strings.TrimSpace(command) + if trimmed == "" { + return command, timeout + } + + aptPattern := regexp.MustCompile(`(^|[;&|]\s*)(apt-get|apt)(\s+)`) + if !aptPattern.MatchString(trimmed) { + return command, timeout + } + + normalized := aptPattern.ReplaceAllString( + command, + `${1}env DEBIAN_FRONTEND=noninteractive APT_LISTCHANGES_FRONTEND=none apt-get -o APT::Sandbox::User=root -o Dpkg::Use-Pty=0 -o Acquire::Retries=3${3}`, + ) + + effectiveTimeout := timeout + if effectiveTimeout < 15*time.Minute { + effectiveTimeout = 15 * time.Minute + } + + return normalized, effectiveTimeout +} + +func normalizeKnownGWSCommandAliases(command string, timeout time.Duration) (string, time.Duration) { + trimmed := strings.TrimSpace(command) + if trimmed == "" { + return command, timeout + } + + if gwsGmailListPattern.MatchString(trimmed) { + return gwsGmailListPattern.ReplaceAllString(command, "gws gmail +triage"), timeout + } + + if !gwsGmailReadPattern.MatchString(trimmed) { + return command, timeout + } + + matches := gwsIDFlagPattern.FindStringSubmatch(command) + var messageID string + for _, candidate := range matches[1:] { + if strings.TrimSpace(candidate) != "" { + messageID = strings.TrimSpace(candidate) + break + } + } + if messageID == "" { + return command, timeout + } + + outputFormat := "json" + if formatMatches := gwsFormatPattern.FindStringSubmatch(command); len(formatMatches) == 2 { + outputFormat = strings.TrimSpace(formatMatches[1]) + if outputFormat == "" { + outputFormat = "json" + } + } + + params := fmt.Sprintf(`{"userId":"me","id":%q,"format":"full"}`, messageID) + normalized := fmt.Sprintf("gws gmail users messages get --params '%s' --format %s", params, outputFormat) + return normalized, timeout +} + func (t *ExecTool) guardCommand(command, cwd string) string { cmd := strings.TrimSpace(command) lower := strings.ToLower(cmd) @@ -305,11 +381,14 @@ func (t *ExecTool) guardCommand(command, cwd string) string { } } - if !explicitlyAllowed { - for _, pattern := range t.denyPatterns { - if pattern.MatchString(lower) { - return "Command blocked by safety guard (dangerous pattern detected)" - } + if explicitlyAllowed { + // Explicitly allowlisted commands bypass all guard checks. + return "" + } + + for _, pattern := range t.denyPatterns { + if pattern.MatchString(lower) { + return "Command blocked by safety guard (dangerous pattern detected)" } } diff --git a/pkg/tools/shell_test.go b/pkg/tools/shell_test.go index ff9ea4a15..5411b3079 100644 --- a/pkg/tools/shell_test.go +++ b/pkg/tools/shell_test.go @@ -212,6 +212,55 @@ func TestShellTool_StderrCapture(t *testing.T) { } } +func TestNormalizeCommandForExecution_AptGetsRootSandboxOverride(t *testing.T) { + command, timeout := normalizeCommandForExecution("apt-get update && apt install -y poppler-utils", 60*time.Second) + if !strings.Contains(command, "APT::Sandbox::User=root") { + t.Fatalf("normalized apt command missing sandbox override: %s", command) + } + if strings.Contains(command, "&& apt install") { + t.Fatalf("expected apt install to be normalized too: %s", command) + } + if timeout < 15*time.Minute { + t.Fatalf("expected extended timeout for apt command, got %v", timeout) + } +} + +func TestNormalizeCommandForExecution_NonAptUnchanged(t *testing.T) { + command, timeout := normalizeCommandForExecution("echo hello", 45*time.Second) + if command != "echo hello" { + t.Fatalf("unexpected command rewrite: %s", command) + } + if timeout != 45*time.Second { + t.Fatalf("unexpected timeout rewrite: %v", timeout) + } +} + +func TestNormalizeCommandForExecution_GWSListAlias(t *testing.T) { + command, timeout := normalizeCommandForExecution("gws gmail list --max 5 --format table", 45*time.Second) + if !strings.Contains(command, "gws gmail +triage --max 5 --format table") { + t.Fatalf("expected gmail list alias to normalize to +triage, got: %s", command) + } + if timeout != 45*time.Second { + t.Fatalf("unexpected timeout rewrite: %v", timeout) + } +} + +func TestNormalizeCommandForExecution_GWSReadAlias(t *testing.T) { + command, timeout := normalizeCommandForExecution("gws gmail +read --id 19ceb978d4dfdfc6 --format table", 45*time.Second) + if !strings.Contains(command, "gws gmail users messages get") { + t.Fatalf("expected gmail +read alias to normalize to users messages get, got: %s", command) + } + if !strings.Contains(command, `"id":"19ceb978d4dfdfc6"`) { + t.Fatalf("expected normalized params to include message id, got: %s", command) + } + if !strings.Contains(command, "--format table") { + t.Fatalf("expected output format to be preserved, got: %s", command) + } + if timeout != 45*time.Second { + t.Fatalf("unexpected timeout rewrite: %v", timeout) + } +} + // TestShellTool_OutputTruncation verifies long output is truncated func TestShellTool_OutputTruncation(t *testing.T) { tool, err := NewExecTool("", false) diff --git a/pkg/tools/skills_install.go b/pkg/tools/skills_install.go index 71bfe730b..38c3f3ad7 100644 --- a/pkg/tools/skills_install.go +++ b/pkg/tools/skills_install.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "sync" "time" @@ -159,12 +160,22 @@ func (t *InstallSkillTool) Execute(ctx context.Context, args map[string]any) *To _ = err } + check, err := skills.VerifyInstalledSkill(targetDir, registry.Name(), slug, result.Version) + if err != nil { + _ = os.RemoveAll(targetDir) + return ErrorResult(fmt.Sprintf("skill %q failed local verification: %v", slug, err)) + } + if !check.Passed { + _ = os.RemoveAll(targetDir) + return ErrorResult(fmt.Sprintf("skill %q blocked by local verification: %s", slug, strings.Join(check.FailureReasons, "; "))) + } + // Build result with moderation warning if suspicious. var output string if result.IsSuspicious { output = fmt.Sprintf("⚠️ Warning: skill %q is flagged as suspicious (may contain risky patterns).\n\n", slug) } - output += fmt.Sprintf("Successfully installed skill %q v%s from %s registry.\nLocation: %s\n", + output += fmt.Sprintf("Successfully installed skill %q v%s from trusted %s registry.\nLocation: %s\nChecked: .skill-check.json written with hashes and policy results.\n", slug, result.Version, registry.Name(), targetDir) if result.Summary != "" { diff --git a/pkg/tools/write_pdf.go b/pkg/tools/write_pdf.go new file mode 100644 index 000000000..fc944886d --- /dev/null +++ b/pkg/tools/write_pdf.go @@ -0,0 +1,139 @@ +package tools + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +const reportlabCheckScript = "import reportlab" + +const writePDFScript = ` +import os +import sys +import textwrap +from reportlab.lib.pagesizes import A4 +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.pdfgen import canvas + +path, title, content = sys.argv[1], sys.argv[2], sys.argv[3] +os.makedirs(os.path.dirname(path) or ".", exist_ok=True) +font_name = "Helvetica" +font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +if os.path.exists(font_path): + pdfmetrics.registerFont(TTFont("DejaVuSans", font_path)) + font_name = "DejaVuSans" + +page_width, page_height = A4 +left = 48 +right = 48 +usable_width = page_width - left - right +line_height = 15 +c = canvas.Canvas(path, pagesize=A4) +y = page_height - 52 + +if title.strip(): + c.setFont(font_name, 16) + c.drawString(left, y, title) + y -= 28 + +c.setFont(font_name, 11) +max_chars = max(30, int(usable_width / 6.2)) +for para in content.splitlines(): + lines = textwrap.wrap(para, width=max_chars, replace_whitespace=False, drop_whitespace=False) or [""] + for line in lines: + if y < 60: + c.showPage() + c.setFont(font_name, 11) + y = page_height - 52 + c.drawString(left, y, line.rstrip()) + y -= line_height + y -= 4 + +c.save() +print(path) +` + +type WritePDFTool struct { + workspace string + restrict bool +} + +func NewWritePDFTool(workspace string, restrict bool) *WritePDFTool { + return &WritePDFTool{workspace: workspace, restrict: restrict} +} + +func (t *WritePDFTool) Name() string { + return "write_pdf" +} + +func (t *WritePDFTool) Description() string { + return "Create a simple PDF file from text content. Use for exports after translating or formatting content." +} + +func (t *WritePDFTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Destination PDF path. Relative paths are resolved from the workspace.", + }, + "title": map[string]any{ + "type": "string", + "description": "Optional document title rendered at the top of the PDF.", + }, + "content": map[string]any{ + "type": "string", + "description": "Document body text to place into the PDF.", + }, + }, + "required": []string{"path", "content"}, + } +} + +func (t *WritePDFTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + pathArg, _ := args["path"].(string) + if strings.TrimSpace(pathArg) == "" { + return ErrorResult("path is required") + } + content, _ := args["content"].(string) + if strings.TrimSpace(content) == "" { + return ErrorResult("content is required") + } + title, _ := args["title"].(string) + + resolved, err := validatePath(pathArg, t.workspace, t.restrict) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid path: %v", err)) + } + if filepath.Ext(strings.ToLower(resolved)) != ".pdf" { + resolved += ".pdf" + } + if err := os.MkdirAll(filepath.Dir(resolved), 0o755); err != nil { + return ErrorResult(fmt.Sprintf("create directory: %v", err)) + } + + if _, err := exec.LookPath("python3"); err != nil { + return ErrorResult("python3 is required to generate PDFs") + } + check := exec.CommandContext(ctx, "python3", "-c", reportlabCheckScript) + if out, err := check.CombinedOutput(); err != nil { + msg := strings.TrimSpace(string(out)) + if msg != "" { + msg = ": " + msg + } + return ErrorResult("reportlab is not installed; install python3-reportlab and retry" + msg) + } + + cmd := exec.CommandContext(ctx, "python3", "-c", writePDFScript, resolved, title, content) + if out, err := cmd.CombinedOutput(); err != nil { + return ErrorResult(fmt.Sprintf("write_pdf failed: %v: %s", err, strings.TrimSpace(string(out)))) + } + + return NewToolResult(fmt.Sprintf("PDF written to %s", resolved)) +} diff --git a/pkg/voice/synthesizer.go b/pkg/voice/synthesizer.go new file mode 100644 index 000000000..30f911785 --- /dev/null +++ b/pkg/voice/synthesizer.go @@ -0,0 +1,280 @@ +// PicoClaw - Ultra-lightweight personal AI agent +// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot +// License: MIT +// +// Copyright (c) 2026 PicoClaw contributors + +package voice + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +type Synthesizer interface { + Name() string + Synthesize(ctx context.Context, text string) (*SynthesisResponse, error) +} + +type SynthesisResponse struct { + AudioFilePath string + ContentType string + Filename string +} + +type GeminiSynthesizer struct { + apiKey string + apiBase string + model string + voiceName string + httpClient *http.Client +} + +type ElevenLabsSynthesizer struct { + apiKey string + apiBase string + modelID string + voiceID string + httpClient *http.Client +} + +type ChainSynthesizer struct { + items []Synthesizer +} + +func (c *ChainSynthesizer) Name() string { + names := make([]string, 0, len(c.items)) + for _, item := range c.items { + if item == nil { + continue + } + names = append(names, item.Name()) + } + return strings.Join(names, "+") +} + +func (c *ChainSynthesizer) Synthesize(ctx context.Context, text string) (*SynthesisResponse, error) { + var lastErr error + for _, item := range c.items { + if item == nil { + continue + } + resp, err := item.Synthesize(ctx, text) + if err == nil { + return resp, nil + } + lastErr = fmt.Errorf("%s: %w", item.Name(), err) + logger.WarnCF("voice", "Speech synthesis fallback", map[string]any{"provider": item.Name(), "error": err.Error()}) + } + if lastErr == nil { + lastErr = fmt.Errorf("no speech synthesizer configured") + } + return nil, lastErr +} + +func NewGeminiSynthesizer(apiKey, apiBase, model, voiceName string) *GeminiSynthesizer { + base := strings.TrimRight(strings.TrimSpace(apiBase), "/") + if base == "" { + base = "https://generativelanguage.googleapis.com/v1beta" + } + if strings.TrimSpace(model) == "" { + model = "gemini-2.5-flash-preview-tts" + } + if strings.TrimSpace(voiceName) == "" { + voiceName = "Kore" + } + return &GeminiSynthesizer{apiKey: strings.TrimSpace(apiKey), apiBase: base, model: strings.TrimSpace(model), voiceName: strings.TrimSpace(voiceName), httpClient: &http.Client{Timeout: 90 * time.Second}} +} + +func (g *GeminiSynthesizer) Name() string { return "gemini" } + +func (g *GeminiSynthesizer) Synthesize(ctx context.Context, text string) (*SynthesisResponse, error) { + if strings.TrimSpace(g.apiKey) == "" { + return nil, fmt.Errorf("missing Gemini API key") + } + payload := map[string]any{ + "contents": []map[string]any{{"parts": []map[string]any{{"text": "Convert the following transcript to speech only. Output audio only and read it exactly as written: " + text}}}}, + "generationConfig": map[string]any{ + "responseModalities": []string{"AUDIO"}, + "speechConfig": map[string]any{"voiceConfig": map[string]any{"prebuiltVoiceConfig": map[string]any{"voiceName": g.voiceName}}}, + }, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + endpoint := fmt.Sprintf("%s/models/%s:generateContent?key=%s", g.apiBase, url.PathEscape(g.model), url.QueryEscape(g.apiKey)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := g.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("gemini tts status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var parsed struct { + Candidates []struct { + Content struct { + Parts []struct { + InlineData struct { + MimeType string `json:"mimeType"` + Data string `json:"data"` + } `json:"inlineData"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("decode gemini tts response: %w", err) + } + var mimeType, data string + for _, candidate := range parsed.Candidates { + for _, part := range candidate.Content.Parts { + if strings.TrimSpace(part.InlineData.Data) != "" { + data = part.InlineData.Data + mimeType = strings.TrimSpace(part.InlineData.MimeType) + break + } + } + if data != "" { + break + } + } + if data == "" { + return nil, fmt.Errorf("gemini tts returned no audio data") + } + audio, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, fmt.Errorf("decode gemini audio: %w", err) + } + return encodeSpeechToTelegramVoice(ctx, audio, mimeType) +} + +func NewElevenLabsSynthesizer(apiKey, apiBase, modelID, voiceID string) *ElevenLabsSynthesizer { + base := strings.TrimRight(strings.TrimSpace(apiBase), "/") + if base == "" { + base = "https://api.elevenlabs.io" + } + if strings.TrimSpace(modelID) == "" { + modelID = "eleven_multilingual_v2" + } + if strings.TrimSpace(voiceID) == "" { + voiceID = "21m00Tcm4TlvDq8ikWAM" + } + return &ElevenLabsSynthesizer{apiKey: strings.TrimSpace(apiKey), apiBase: base, modelID: strings.TrimSpace(modelID), voiceID: strings.TrimSpace(voiceID), httpClient: &http.Client{Timeout: 90 * time.Second}} +} + +func (e *ElevenLabsSynthesizer) Name() string { return "elevenlabs" } + +func (e *ElevenLabsSynthesizer) Synthesize(ctx context.Context, text string) (*SynthesisResponse, error) { + if strings.TrimSpace(e.apiKey) == "" { + return nil, fmt.Errorf("missing ElevenLabs API key") + } + body, err := json.Marshal(map[string]any{"text": text, "model_id": e.modelID, "output_format": "mp3_44100_128"}) + if err != nil { + return nil, err + } + endpoint := fmt.Sprintf("%s/v1/text-to-speech/%s", e.apiBase, url.PathEscape(e.voiceID)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("xi-api-key", e.apiKey) + req.Header.Set("Accept", "audio/mpeg") + resp, err := e.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("elevenlabs tts status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + return encodeSpeechToTelegramVoice(ctx, raw, "audio/mpeg") +} + +func encodeSpeechToTelegramVoice(ctx context.Context, raw []byte, mimeType string) (*SynthesisResponse, error) { + mime := strings.ToLower(strings.TrimSpace(mimeType)) + if mime == "" { + mime = "audio/L16;rate=24000" + } + tmpDir, err := os.MkdirTemp("", "picoclaw-tts-") + if err != nil { + return nil, err + } + inputPath := filepath.Join(tmpDir, "input.bin") + outputPath := filepath.Join(tmpDir, "reply.ogg") + if strings.Contains(mime, "mpeg") || strings.Contains(mime, "mp3") { + inputPath = filepath.Join(tmpDir, "input.mp3") + } else if strings.Contains(mime, "wav") || strings.Contains(mime, "wave") { + inputPath = filepath.Join(tmpDir, "input.wav") + } else if strings.Contains(mime, "ogg") || strings.Contains(mime, "opus") { + inputPath = filepath.Join(tmpDir, "input.ogg") + } else if strings.Contains(mime, "pcm") || strings.Contains(mime, "l16") { + inputPath = filepath.Join(tmpDir, "input.pcm") + } + if err := os.WriteFile(inputPath, raw, 0o600); err != nil { + return nil, err + } + args := []string{"-y"} + if strings.HasSuffix(inputPath, ".pcm") { + args = append(args, "-f", "s16le", "-ar", "24000", "-ac", "1") + } + args = append(args, "-i", inputPath, "-c:a", "libopus", "-b:a", "24k", outputPath) + cmd := exec.CommandContext(ctx, "ffmpeg", args...) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("ffmpeg voice transcode failed: %v: %s", err, strings.TrimSpace(string(out))) + } + return &SynthesisResponse{AudioFilePath: outputPath, ContentType: "audio/ogg", Filename: "reply.ogg"}, nil +} + +func DetectSynthesizer(cfg *config.Config) Synthesizer { + if cfg == nil { + return nil + } + var chain []Synthesizer + for _, mc := range cfg.ModelList { + if strings.HasPrefix(strings.TrimSpace(mc.Model), "elevenlabs/") && strings.TrimSpace(mc.APIKey) != "" { + chain = append(chain, NewElevenLabsSynthesizer(mc.APIKey, mc.APIBase, strings.TrimPrefix(strings.TrimSpace(mc.Model), "elevenlabs/"), os.Getenv("PICOCLAW_ELEVENLABS_VOICE_ID"))) + break + } + } + if key := strings.TrimSpace(cfg.Providers.Gemini.APIKey); key != "" { + chain = append(chain, NewGeminiSynthesizer(key, cfg.Providers.Gemini.APIBase, os.Getenv("PICOCLAW_GEMINI_TTS_MODEL"), os.Getenv("PICOCLAW_GEMINI_TTS_VOICE"))) + } + if len(chain) == 0 { + return nil + } + if len(chain) == 1 { + return chain[0] + } + return &ChainSynthesizer{items: chain} +} diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go index e949d7a22..01bc6f62d 100644 --- a/pkg/voice/transcriber.go +++ b/pkg/voice/transcriber.go @@ -3,11 +3,13 @@ package voice import ( "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" "mime/multipart" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -23,158 +25,466 @@ type Transcriber interface { Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) } +type OpenRouterTranscriber struct { + apiKey string + apiBase string + model string + httpClient *http.Client +} + +type GeminiTranscriber struct { + apiKey string + apiBase string + model string + httpClient *http.Client +} + type GroqTranscriber struct { apiKey string apiBase string httpClient *http.Client } +type ElevenLabsTranscriber struct { + apiKey string + apiBase string + modelID string + httpClient *http.Client +} + +type ChainTranscriber struct { + items []Transcriber +} + type TranscriptionResponse struct { Text string `json:"text"` Language string `json:"language,omitempty"` Duration float64 `json:"duration,omitempty"` } +func (c *ChainTranscriber) Name() string { + names := make([]string, 0, len(c.items)) + for _, item := range c.items { + if item == nil { + continue + } + names = append(names, item.Name()) + } + return strings.Join(names, "+") +} + +func (c *ChainTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + var lastErr error + for _, item := range c.items { + if item == nil { + continue + } + resp, err := item.Transcribe(ctx, audioFilePath) + if err == nil { + return resp, nil + } + lastErr = fmt.Errorf("%s: %w", item.Name(), err) + logger.WarnCF("voice", "Transcription fallback", map[string]any{"provider": item.Name(), "error": err.Error()}) + } + if lastErr == nil { + lastErr = fmt.Errorf("no transcription provider configured") + } + return nil, lastErr +} + +func NewOpenRouterTranscriber(apiKey, apiBase, model string) *OpenRouterTranscriber { + base := strings.TrimRight(strings.TrimSpace(apiBase), "/") + if base == "" { + base = "https://openrouter.ai/api/v1" + } + model = strings.TrimSpace(model) + if model == "" { + model = "openrouter/openrouter/free" + } + return &OpenRouterTranscriber{apiKey: strings.TrimSpace(apiKey), apiBase: base, model: model, httpClient: &http.Client{Timeout: 90 * time.Second}} +} + +func (t *OpenRouterTranscriber) Name() string { return "openrouter" } + +func (t *OpenRouterTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + if strings.TrimSpace(t.apiKey) == "" { + return nil, fmt.Errorf("missing OpenRouter API key") + } + audio, format, _, err := readAudioInput(audioFilePath) + if err != nil { + return nil, err + } + payload := map[string]any{ + "model": t.model, + "messages": []map[string]any{{ + "role": "user", + "content": []map[string]any{ + {"type": "text", "text": "Transcribe this audio verbatim. Return only the spoken words with no commentary, labels, or markdown."}, + {"type": "input_audio", "input_audio": map[string]any{"data": base64.StdEncoding.EncodeToString(audio), "format": format}}, + }, + }}, + "temperature": 0, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + endpoint := t.apiBase + "/chat/completions" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+t.apiKey) + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("openrouter stt status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + text, err := parseOpenAICompatTranscript(raw) + if err != nil { + return nil, err + } + return &TranscriptionResponse{Text: text}, nil +} + +func NewGeminiTranscriber(apiKey, apiBase, model string) *GeminiTranscriber { + base := strings.TrimRight(strings.TrimSpace(apiBase), "/") + if base == "" { + base = "https://generativelanguage.googleapis.com/v1beta" + } + model = strings.TrimSpace(model) + if model == "" { + model = "gemini-2.5-flash" + } + return &GeminiTranscriber{apiKey: strings.TrimSpace(apiKey), apiBase: base, model: model, httpClient: &http.Client{Timeout: 90 * time.Second}} +} + +func (t *GeminiTranscriber) Name() string { return "gemini" } + +func (t *GeminiTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + if strings.TrimSpace(t.apiKey) == "" { + return nil, fmt.Errorf("missing Gemini API key") + } + audio, _, mimeType, err := readAudioInput(audioFilePath) + if err != nil { + return nil, err + } + payload := map[string]any{ + "contents": []map[string]any{{"parts": []map[string]any{ + {"text": "Generate a verbatim transcript of the speech in this audio. Return only the spoken words with no commentary, labels, or markdown."}, + {"inlineData": map[string]any{"mimeType": mimeType, "data": base64.StdEncoding.EncodeToString(audio)}}, + }}}, + "generationConfig": map[string]any{"temperature": 0}, + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + endpoint := fmt.Sprintf("%s/models/%s:generateContent?key=%s", t.apiBase, url.PathEscape(t.model), url.QueryEscape(t.apiKey)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("gemini stt status %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + var parsed struct { + Candidates []struct { + Content struct { + Parts []struct { + Text string `json:"text"` + } `json:"parts"` + } `json:"content"` + } `json:"candidates"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return nil, fmt.Errorf("decode gemini transcript: %w", err) + } + var text string + for _, candidate := range parsed.Candidates { + for _, part := range candidate.Content.Parts { + if strings.TrimSpace(part.Text) != "" { + text = strings.TrimSpace(part.Text) + break + } + } + if text != "" { + break + } + } + if text == "" { + return nil, fmt.Errorf("gemini returned empty transcription") + } + return &TranscriptionResponse{Text: text}, nil +} + func NewGroqTranscriber(apiKey string) *GroqTranscriber { logger.DebugCF("voice", "Creating Groq transcriber", map[string]any{"has_api_key": apiKey != ""}) - apiBase := "https://api.groq.com/openai/v1" - return &GroqTranscriber{ - apiKey: apiKey, - apiBase: apiBase, - httpClient: &http.Client{ - Timeout: 60 * time.Second, - }, - } + return &GroqTranscriber{apiKey: apiKey, apiBase: apiBase, httpClient: &http.Client{Timeout: 60 * time.Second}} } func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { - logger.InfoCF("voice", "Starting transcription", map[string]any{"audio_file": audioFilePath}) - + logger.InfoCF("voice", "Starting transcription", map[string]any{"provider": "groq", "audio_file": audioFilePath}) audioFile, err := os.Open(audioFilePath) if err != nil { logger.ErrorCF("voice", "Failed to open audio file", map[string]any{"path": audioFilePath, "error": err}) return nil, fmt.Errorf("failed to open audio file: %w", err) } defer audioFile.Close() - fileInfo, err := audioFile.Stat() if err != nil { logger.ErrorCF("voice", "Failed to get file info", map[string]any{"path": audioFilePath, "error": err}) return nil, fmt.Errorf("failed to get file info: %w", err) } - - logger.DebugCF("voice", "Audio file details", map[string]any{ - "size_bytes": fileInfo.Size(), - "file_name": filepath.Base(audioFilePath), - }) - var requestBody bytes.Buffer writer := multipart.NewWriter(&requestBody) - part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) if err != nil { - logger.ErrorCF("voice", "Failed to create form file", map[string]any{"error": err}) return nil, fmt.Errorf("failed to create form file: %w", err) } - - copied, err := io.Copy(part, audioFile) - if err != nil { - logger.ErrorCF("voice", "Failed to copy file content", map[string]any{"error": err}) + if _, err := io.Copy(part, audioFile); err != nil { return nil, fmt.Errorf("failed to copy file content: %w", err) } - - logger.DebugCF("voice", "File copied to request", map[string]any{"bytes_copied": copied}) - - if err = writer.WriteField("model", "whisper-large-v3"); err != nil { - logger.ErrorCF("voice", "Failed to write model field", map[string]any{"error": err}) + if err := writer.WriteField("model", "whisper-large-v3"); err != nil { return nil, fmt.Errorf("failed to write model field: %w", err) } - - if err = writer.WriteField("response_format", "json"); err != nil { - logger.ErrorCF("voice", "Failed to write response_format field", map[string]any{"error": err}) + if err := writer.WriteField("response_format", "json"); err != nil { return nil, fmt.Errorf("failed to write response_format field: %w", err) } - - if err = writer.Close(); err != nil { - logger.ErrorCF("voice", "Failed to close multipart writer", map[string]any{"error": err}) + if err := writer.Close(); err != nil { return nil, fmt.Errorf("failed to close multipart writer: %w", err) } - url := t.apiBase + "/audio/transcriptions" - req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &requestBody) if err != nil { - logger.ErrorCF("voice", "Failed to create request", map[string]any{"error": err}) return nil, fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("Content-Type", writer.FormDataContentType()) req.Header.Set("Authorization", "Bearer "+t.apiKey) - - logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]any{ - "url": url, - "request_size_bytes": requestBody.Len(), - "file_size_bytes": fileInfo.Size(), - }) - resp, err := t.httpClient.Do(req) if err != nil { - logger.ErrorCF("voice", "Failed to send request", map[string]any{"error": err}) return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) if err != nil { - logger.ErrorCF("voice", "Failed to read response", map[string]any{"error": err}) return nil, fmt.Errorf("failed to read response: %w", err) } - if resp.StatusCode != http.StatusOK { - logger.ErrorCF("voice", "API error", map[string]any{ - "status_code": resp.StatusCode, - "response": string(body), - }) return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) } - - logger.DebugCF("voice", "Received response from Groq API", map[string]any{ - "status_code": resp.StatusCode, - "response_size_bytes": len(body), - }) - var result TranscriptionResponse if err := json.Unmarshal(body, &result); err != nil { - logger.ErrorCF("voice", "Failed to unmarshal response", map[string]any{"error": err}) return nil, fmt.Errorf("failed to unmarshal response: %w", err) } - - logger.InfoCF("voice", "Transcription completed successfully", map[string]any{ - "text_length": len(result.Text), - "language": result.Language, - "duration_seconds": result.Duration, - "transcription_preview": utils.Truncate(result.Text, 50), - }) - + logger.InfoCF("voice", "Transcription completed successfully", map[string]any{"provider": "groq", "text_length": len(result.Text), "language": result.Language, "duration_seconds": result.Duration, "file_size_bytes": fileInfo.Size(), "transcription_preview": utils.Truncate(result.Text, 50)}) return &result, nil } -func (t *GroqTranscriber) Name() string { - return "groq" +func (t *GroqTranscriber) Name() string { return "groq" } + +func NewElevenLabsTranscriber(apiKey, apiBase, modelID string) *ElevenLabsTranscriber { + base := strings.TrimSpace(apiBase) + if base == "" { + base = "https://api.elevenlabs.io" + } + base = strings.TrimRight(base, "/") + model := strings.TrimSpace(modelID) + if model == "" { + model = "scribe_v1" + } + logger.DebugCF("voice", "Creating ElevenLabs transcriber", map[string]any{"has_api_key": apiKey != "", "api_base": base, "model_id": model}) + return &ElevenLabsTranscriber{apiKey: apiKey, apiBase: base, modelID: model, httpClient: &http.Client{Timeout: 60 * time.Second}} } -// DetectTranscriber inspects cfg and returns the appropriate Transcriber, or -// nil if no supported transcription provider is configured. -func DetectTranscriber(cfg *config.Config) Transcriber { - // Direct Groq provider config takes priority. - if key := cfg.Providers.Groq.APIKey; key != "" { - return NewGroqTranscriber(key) +func (t *ElevenLabsTranscriber) Name() string { return "elevenlabs" } + +func (t *ElevenLabsTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { + logger.InfoCF("voice", "Starting transcription", map[string]any{"provider": "elevenlabs", "audio_file": audioFilePath}) + audioFile, err := os.Open(audioFilePath) + if err != nil { + return nil, fmt.Errorf("failed to open audio file: %w", err) } - // Fall back to any model-list entry that uses the groq/ protocol. + defer audioFile.Close() + fileInfo, err := audioFile.Stat() + if err != nil { + return nil, fmt.Errorf("failed to get file info: %w", err) + } + var requestBody bytes.Buffer + writer := multipart.NewWriter(&requestBody) + part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) + if err != nil { + return nil, fmt.Errorf("failed to create form file: %w", err) + } + if _, err := io.Copy(part, audioFile); err != nil { + return nil, fmt.Errorf("failed to copy file content: %w", err) + } + if err := writer.WriteField("model_id", t.modelID); err != nil { + return nil, fmt.Errorf("failed to write model_id field: %w", err) + } + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + url := t.apiBase + "/v1/speech-to-text" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &requestBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Header.Set("xi-api-key", t.apiKey) + req.Header.Set("Accept", "application/json") + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) + } + var raw struct { + Text string `json:"text"` + LanguageCode string `json:"language_code,omitempty"` + } + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + if strings.TrimSpace(raw.Text) == "" { + return nil, fmt.Errorf("empty transcription response from ElevenLabs") + } + result := &TranscriptionResponse{Text: raw.Text, Language: raw.LanguageCode} + logger.InfoCF("voice", "Transcription completed successfully", map[string]any{"provider": "elevenlabs", "text_length": len(result.Text), "language": result.Language, "file_size_bytes": fileInfo.Size(), "transcription_preview": utils.Truncate(result.Text, 50)}) + return result, nil +} + +func DetectTranscriber(cfg *config.Config) Transcriber { + if cfg == nil { + return nil + } + var chain []Transcriber for _, mc := range cfg.ModelList { - if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" { - return NewGroqTranscriber(mc.APIKey) + if strings.HasPrefix(strings.TrimSpace(mc.Model), "elevenlabs/") && strings.TrimSpace(mc.APIKey) != "" { + chain = append(chain, NewElevenLabsTranscriber(mc.APIKey, mc.APIBase, strings.TrimPrefix(strings.TrimSpace(mc.Model), "elevenlabs/"))) + break } } - return nil + if key := strings.TrimSpace(cfg.Providers.Gemini.APIKey); key != "" { + chain = append(chain, NewGeminiTranscriber(key, cfg.Providers.Gemini.APIBase, os.Getenv("PICOCLAW_GEMINI_STT_MODEL"))) + } + if key := strings.TrimSpace(cfg.Providers.Groq.APIKey); key != "" { + chain = append(chain, NewGroqTranscriber(key)) + } else { + for _, mc := range cfg.ModelList { + if strings.HasPrefix(strings.TrimSpace(mc.Model), "groq/") && strings.TrimSpace(mc.APIKey) != "" { + chain = append(chain, NewGroqTranscriber(mc.APIKey)) + break + } + } + } + if len(chain) == 0 { + return nil + } + if len(chain) == 1 { + return chain[0] + } + return &ChainTranscriber{items: chain} +} + +func readAudioInput(path string) ([]byte, string, string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, "", "", fmt.Errorf("failed to read audio file: %w", err) + } + format := audioFormatFromPath(path) + mimeType := audioMimeTypeFromFormat(format) + return raw, format, mimeType, nil +} + +func audioFormatFromPath(path string) string { + ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), ".") + switch ext { + case "mp3", "wav", "ogg", "webm", "mp4", "mpeg", "mpga": + return ext + case "oga", "opus": + return "ogg" + case "m4a": + return "mp4" + default: + return "wav" + } +} + +func audioMimeTypeFromFormat(format string) string { + switch strings.ToLower(strings.TrimSpace(format)) { + case "mp3", "mpga", "mpeg": + return "audio/mpeg" + case "ogg": + return "audio/ogg" + case "webm": + return "audio/webm" + case "mp4": + return "audio/mp4" + default: + return "audio/wav" + } +} + +func parseOpenAICompatTranscript(raw []byte) (string, error) { + var parsed struct { + Choices []struct { + Message struct { + Content any `json:"content"` + } `json:"message"` + } `json:"choices"` + } + if err := json.Unmarshal(raw, &parsed); err != nil { + return "", fmt.Errorf("decode openai-compatible transcript: %w", err) + } + for _, choice := range parsed.Choices { + if text := strings.TrimSpace(extractContentText(choice.Message.Content)); text != "" { + return text, nil + } + } + return "", fmt.Errorf("openai-compatible provider returned empty transcription") +} + +func extractContentText(content any) string { + switch v := content.(type) { + case string: + return v + case []any: + parts := make([]string, 0, len(v)) + for _, item := range v { + if m, ok := item.(map[string]any); ok { + if text, ok := m["text"].(string); ok && strings.TrimSpace(text) != "" { + parts = append(parts, strings.TrimSpace(text)) + } + } + } + return strings.Join(parts, "\n") + default: + return "" + } } diff --git a/pkg/voice/transcriber_test.go b/pkg/voice/transcriber_test.go index 9b6add333..924c1528f 100644 --- a/pkg/voice/transcriber_test.go +++ b/pkg/voice/transcriber_test.go @@ -3,17 +3,36 @@ package voice import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/sipeed/picoclaw/pkg/config" ) -// Ensure GroqTranscriber satisfies the Transcriber interface at compile time. +var _ Transcriber = (*OpenRouterTranscriber)(nil) +var _ Transcriber = (*GeminiTranscriber)(nil) var _ Transcriber = (*GroqTranscriber)(nil) +var _ Transcriber = (*ElevenLabsTranscriber)(nil) +var _ Transcriber = (*ChainTranscriber)(nil) + +func TestOpenRouterTranscriberName(t *testing.T) { + tr := NewOpenRouterTranscriber("sk-test", "", "") + if got := tr.Name(); got != "openrouter" { + t.Errorf("Name() = %q, want %q", got, "openrouter") + } +} + +func TestGeminiTranscriberName(t *testing.T) { + tr := NewGeminiTranscriber("gem-test", "", "") + if got := tr.Name(); got != "gemini" { + t.Errorf("Name() = %q, want %q", got, "gemini") + } +} func TestGroqTranscriberName(t *testing.T) { tr := NewGroqTranscriber("sk-test") @@ -22,6 +41,13 @@ func TestGroqTranscriberName(t *testing.T) { } } +func TestElevenLabsTranscriberName(t *testing.T) { + tr := NewElevenLabsTranscriber("sk-test", "", "") + if got := tr.Name(); got != "elevenlabs" { + t.Errorf("Name() = %q, want %q", got, "elevenlabs") + } +} + func TestDetectTranscriber(t *testing.T) { tests := []struct { name string @@ -29,53 +55,33 @@ func TestDetectTranscriber(t *testing.T) { wantNil bool wantName string }{ + {name: "no config", cfg: &config.Config{}, wantNil: true}, { - name: "no config", - cfg: &config.Config{}, - wantNil: true, + name: "elevenlabs first", + cfg: &config.Config{ModelList: []config.ModelConfig{{Model: "elevenlabs/scribe_v1", APIBase: "https://api.elevenlabs.io", APIKey: "xi-key"}}}, + wantName: "elevenlabs", }, { - name: "groq provider key", - cfg: &config.Config{ - Providers: config.ProvidersConfig{ - Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, - }, - }, + name: "elevenlabs then gemini chain", + cfg: &config.Config{Providers: config.ProvidersConfig{Gemini: config.ProviderConfig{APIKey: "gem-key"}}, ModelList: []config.ModelConfig{{Model: "elevenlabs/scribe_v1", APIBase: "https://api.elevenlabs.io", APIKey: "xi-key"}}}, + wantName: "elevenlabs+gemini", + }, + { + name: "gemini ahead of groq", + cfg: &config.Config{Providers: config.ProvidersConfig{Gemini: config.ProviderConfig{APIKey: "gem-key"}, Groq: config.ProviderConfig{APIKey: "groq-key"}}}, + wantName: "gemini+groq", + }, + { + name: "groq via provider key", + cfg: &config.Config{Providers: config.ProvidersConfig{Groq: config.ProviderConfig{APIKey: "groq-key"}}}, wantName: "groq", }, { - name: "groq via model list", - cfg: &config.Config{ - ModelList: []config.ModelConfig{ - {Model: "openai/gpt-4o", APIKey: "sk-openai"}, - {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, - }, - }, - wantName: "groq", - }, - { - name: "groq model list entry without key is skipped", - cfg: &config.Config{ - ModelList: []config.ModelConfig{ - {Model: "groq/llama-3.3-70b", APIKey: ""}, - }, - }, - wantNil: true, - }, - { - name: "provider key takes priority over model list", - cfg: &config.Config{ - Providers: config.ProvidersConfig{ - Groq: config.ProviderConfig{APIKey: "sk-groq-direct"}, - }, - ModelList: []config.ModelConfig{ - {Model: "groq/llama-3.3-70b", APIKey: "sk-groq-model"}, - }, - }, + name: "groq via model list", + cfg: &config.Config{ModelList: []config.ModelConfig{{Model: "groq/whisper-large-v3", APIKey: "sk-groq-model"}}}, wantName: "groq", }, } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tr := DetectTranscriber(tc.cfg) @@ -96,14 +102,63 @@ func TestDetectTranscriber(t *testing.T) { } func TestTranscribe(t *testing.T) { - // Write a minimal fake audio file so the transcriber can open and send it. tmpDir := t.TempDir() - audioPath := filepath.Join(tmpDir, "clip.ogg") + audioPath := filepath.Join(tmpDir, "clip.wav") if err := os.WriteFile(audioPath, []byte("fake-audio-data"), 0o644); err != nil { t.Fatalf("failed to write fake audio file: %v", err) } - t.Run("success", func(t *testing.T) { + t.Run("openrouter success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer sk-or" { + t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) + } + body, _ := io.ReadAll(r.Body) + if !strings.Contains(string(body), "input_audio") { + t.Errorf("request body missing input_audio: %s", string(body)) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"message":{"content":"hello from openrouter"}}]}`)) + })) + defer srv.Close() + + tr := NewOpenRouterTranscriber("sk-or", srv.URL, "openrouter/openrouter/free") + resp, err := tr.Transcribe(context.Background(), audioPath) + if err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + if resp.Text != "hello from openrouter" { + t.Errorf("Text = %q, want %q", resp.Text, "hello from openrouter") + } + }) + + t.Run("gemini success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/models/gemini-2.5-flash:generateContent") { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("key"); got != "gem-key" { + t.Errorf("unexpected key query: %s", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"candidates":[{"content":{"parts":[{"text":"hello from gemini"}]}}]}`)) + })) + defer srv.Close() + + tr := NewGeminiTranscriber("gem-key", srv.URL, "gemini-2.5-flash") + resp, err := tr.Transcribe(context.Background(), audioPath) + if err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + if resp.Text != "hello from gemini" { + t.Errorf("Text = %q, want %q", resp.Text, "hello from gemini") + } + }) + + t.Run("groq success", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/audio/transcriptions" { t.Errorf("unexpected path: %s", r.URL.Path) @@ -112,17 +167,12 @@ func TestTranscribe(t *testing.T) { t.Errorf("unexpected Authorization header: %s", r.Header.Get("Authorization")) } w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(TranscriptionResponse{ - Text: "hello world", - Language: "en", - Duration: 1.5, - }) + _ = json.NewEncoder(w).Encode(TranscriptionResponse{Text: "hello world", Language: "en", Duration: 1.5}) })) defer srv.Close() tr := NewGroqTranscriber("sk-test") tr.apiBase = srv.URL - resp, err := tr.Transcribe(context.Background(), audioPath) if err != nil { t.Fatalf("Transcribe() error: %v", err) @@ -130,28 +180,36 @@ func TestTranscribe(t *testing.T) { if resp.Text != "hello world" { t.Errorf("Text = %q, want %q", resp.Text, "hello world") } + }) + + t.Run("elevenlabs success", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/speech-to-text" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + if r.Header.Get("xi-api-key") != "xi-test" { + t.Errorf("unexpected xi-api-key header: %s", r.Header.Get("xi-api-key")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"text":"hello from eleven","language_code":"en"}`)) + })) + defer srv.Close() + + tr := NewElevenLabsTranscriber("xi-test", srv.URL, "scribe_v1") + resp, err := tr.Transcribe(context.Background(), audioPath) + if err != nil { + t.Fatalf("Transcribe() error: %v", err) + } + if resp.Text != "hello from eleven" { + t.Errorf("Text = %q, want %q", resp.Text, "hello from eleven") + } if resp.Language != "en" { t.Errorf("Language = %q, want %q", resp.Language, "en") } }) - t.Run("api error", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, `{"error":"invalid_api_key"}`, http.StatusUnauthorized) - })) - defer srv.Close() - - tr := NewGroqTranscriber("sk-bad") - tr.apiBase = srv.URL - - _, err := tr.Transcribe(context.Background(), audioPath) - if err == nil { - t.Fatal("expected error for non-200 response, got nil") - } - }) - t.Run("missing file", func(t *testing.T) { - tr := NewGroqTranscriber("sk-test") + tr := NewOpenRouterTranscriber("sk-or", "", "") _, err := tr.Transcribe(context.Background(), filepath.Join(tmpDir, "nonexistent.ogg")) if err == nil { t.Fatal("expected error for missing file, got nil")