Backup Orange Pi live worktree with routing, preview, and GWS fixes

This commit is contained in:
Bernardo 2026-03-14 11:53:48 +01:00
parent e55b3b7a8d
commit 0eccc5f89f
43 changed files with 9643 additions and 624 deletions

View file

@ -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)

View file

@ -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 <slug>`")
}
// 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)

View file

@ -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\":\"<message-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\":\"<id>\"}' --json '{\"role\":\"reader\",\"type\":\"anyone\"}'",
"If a Google Workspace request fails, continue troubleshooting in chat with gws auth status or gws <product> --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)
}

View file

@ -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)
}
}

View file

@ -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) != "" {

File diff suppressed because it is too large Load diff

View file

@ -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."
}

View file

@ -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)
}
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -13,7 +13,10 @@ import (
// AgentRegistry manages multiple agent instances and routes messages to them.
type AgentRegistry struct {
agents map[string]*AgentInstance
profileAgents map[string]*AgentInstance
resolver *routing.RouteResolver
cfg *config.Config
provider providers.LLMProvider
mu sync.RWMutex
}
@ -24,7 +27,10 @@ func NewAgentRegistry(
) *AgentRegistry {
registry := &AgentRegistry{
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.

File diff suppressed because it is too large Load diff

View file

@ -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(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>PicoClaw Dashboard</title>
<style>
:root {
--bg: radial-gradient(1200px 600px at 10% -10%, #243b55 0%, #0f2027 35%, #0b0f14 100%);
--card: rgba(255,255,255,0.06);
--card-border: rgba(255,255,255,0.12);
--text: #eaf2ff;
--muted: #9fb4d1;
--ok: #1fd29b;
--warn: #ffd166;
--accent: #65b7ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
.wrap { max-width: 1080px; margin: 0 auto; padding: 28px 18px 36px; }
.hero {
display: flex; justify-content: space-between; align-items: flex-start; gap: 20px;
margin-bottom: 18px;
}
.title { font-size: 30px; font-weight: 700; letter-spacing: 0.4px; margin: 0; }
.sub { margin: 6px 0 0; color: var(--muted); font-size: 14px; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.card {
background: var(--card);
border: 1px solid var(--card-border);
border-radius: 12px;
padding: 14px;
backdrop-filter: blur(8px);
}
.k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.6px; }
.v { font-size: 18px; margin-top: 6px; font-weight: 650; }
.list { margin: 0; padding-left: 18px; line-height: 1.6; }
.row { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.chip {
border: 1px solid var(--card-border);
border-radius: 999px;
padding: 6px 10px;
font-size: 12px;
color: var(--muted);
background: rgba(255,255,255,0.04);
}
.ok { color: var(--ok); }
.warn { color: var(--warn); }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.footer { margin-top: 20px; color: var(--muted); font-size: 12px; }
</style>
</head>
<body>
<div class="wrap">
<div class="hero">
<div>
<h1 class="title">PicoClaw Dashboard</h1>
<p class="sub">Live runtime overview for channels and health endpoints</p>
</div>
<div class="chip">Generated {{.GeneratedAt}}</div>
</div>
<div class="grid">
<div class="card">
<div class="k">Service</div>
<div class="v">picoclaw <span class="ok">online</span></div>
</div>
<div class="card">
<div class="k">Gateway</div>
<div class="v">{{.Address}}</div>
</div>
<div class="card">
<div class="k">Channels Enabled</div>
<div class="v">{{len .Channels}}</div>
</div>
<div class="card">
<div class="k">Health</div>
<div class="v"><a href="/health">/health</a> · <a href="/ready">/ready</a></div>
</div>
</div>
<div class="card" style="margin-bottom:12px;">
<div class="k">Channel List</div>
{{if .Channels}}
<div class="row">
{{range .Channels}}<span class="chip">{{.}}</span>{{end}}
</div>
{{else}}
<p class="warn">No channels currently registered.</p>
{{end}}
</div>
<div class="card">
<div class="k">Webhook Routes</div>
{{if .Webhooks}}
<ul class="list">
{{range .Webhooks}}
<li><strong>{{.Name}}</strong>: <a href="{{.Path}}">{{.Path}}</a></li>
{{end}}
</ul>
{{else}}
<p class="warn">No webhook routes registered.</p>
{{end}}
</div>
<p class="footer">Tip: this page auto-refreshes health status every 15s using /health and /ready.</p>
</div>
<script>
async function ping(path) {
try {
const r = await fetch(path, { cache: "no-store" });
return r.ok;
} catch (_) { return false; }
}
async function run() {
const h = await ping("/health");
const r = await ping("/ready");
const title = document.querySelector(".title");
if (title) {
title.textContent = h && r ? "PicoClaw Dashboard" : "PicoClaw Dashboard (degraded)";
}
setTimeout(run, 15000);
}
run();
</script>
</body>
</html>`))
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,6 +237,13 @@ 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
@ -84,6 +253,10 @@ type Manager struct {
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()
@ -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()

View file

@ -6,9 +6,11 @@ import (
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/mymmrac/telego"
@ -26,14 +28,15 @@ import (
)
var (
reHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
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(`^[-*]\s+`)
reListItem = regexp.MustCompile(`(?m)^[-*]\s+`)
reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
reInlineCode = regexp.MustCompile("`([^`]+)`")
)
@ -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
}
@ -98,6 +106,9 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
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),
@ -529,6 +667,20 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
"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 <instruction>")
}
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, `<a href="$2">$1</a>`)
text = reBoldItalicStar.ReplaceAllString(text, "<b><i>$1</i></b>")
text = reBoldStar.ReplaceAllString(text, "<b>$1</b>")
text = reBoldUnder.ReplaceAllString(text, "<b>$1</b>")
@ -620,6 +829,8 @@ func markdownToTelegramHTML(text string) string {
return "<i>" + match[1] + "</i>"
})
text = replaceSingleAsteriskItalics(text)
text = reStrike.ReplaceAllString(text, "<s>$1</s>")
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("<i>")
b.WriteString(content)
b.WriteString("</i>")
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
}

View file

@ -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
}

View file

@ -12,6 +12,8 @@ func BuiltinDefinitions() []Definition {
listCommand(),
switchCommand(),
checkCommand(),
runCommand(),
execCommand(),
clearCommand(),
}
}

View file

@ -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 <name> | /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, ", "))
},
},
},
}
}

View file

@ -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.")
},
}
}

14
pkg/commands/cmd_exec.go Normal file
View file

@ -0,0 +1,14 @@
package commands
import "context"
func execCommand() Definition {
return Definition{
Name: "exec",
Description: "Alias for /run",
Usage: "/exec <command>",
Handler: func(ctx context.Context, req Request, rt *Runtime) error {
return executeShellCommand(ctx, req, rt, "/exec", "!exec")
},
}
}

View file

@ -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")
}

47
pkg/commands/cmd_run.go Normal file
View file

@ -0,0 +1,47 @@
package commands
import (
"context"
"strings"
)
func runCommand() Definition {
return Definition{
Name: "run",
Description: "Execute a shell command",
Usage: "/run <command>",
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], "!") + " <command>")
}
return req.Reply("Usage: /run <command>")
}
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)
}

View file

@ -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 {
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()
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))
},
},
{
Name: "channel",
Description: "Current channel",
Handler: func(_ context.Context, req Request, _ *Runtime) error {
case "channel":
return req.Reply(fmt.Sprintf("Current Channel: %s", req.Channel))
},
},
{
Name: "agents",
Description: "Registered agents",
Handler: agentsHandler(),
},
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]")
}
},
}
}

View file

@ -3,24 +3,44 @@ 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 <name>",
Usage: "/switch [model to <name>|channel]",
Handler: func(_ context.Context, req Request, rt *Runtime) error {
if rt == nil || rt.SwitchModel == nil {
if rt == nil {
return req.Reply(unavailableMsg)
}
// Parse: /switch model to <value>
value := nthToken(req.Text, 3) // tokens: [/switch, model, to, <value>]
if nthToken(req.Text, 2) != "to" || value == "" {
arg1 := normalizeCommandName(nthToken(req.Text, 1))
if arg1 == "" {
return req.Reply("Usage: /switch [model to <name>|channel]")
}
if arg1 == "channel" {
return req.Reply("This command has moved. Please use: /check channel <name>")
}
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 <name>")
}
value = nthToken(req.Text, 3)
} else {
// Convenience form: /switch <name>
value = nthToken(req.Text, 1)
}
value = normalizeSwitchModelValue(value)
if value == "" {
return req.Reply("Usage: /switch model to <name>")
}
oldModel, err := rt.SwitchModel(value)
@ -29,14 +49,18 @@ func switchCommand() Definition {
}
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 <name>")
},
},
},
}
}
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
}
}

View file

@ -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}
}

View file

@ -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
}

View file

@ -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"`

View file

@ -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,10 +392,15 @@ 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)
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
}
}
}
}
// Build ToolCall with ExtraContent for Gemini 3 thought_signature persistence
toolCall := ToolCall{
@ -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:

View file

@ -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{

149
pkg/skills/install_check.go Normal file
View file

@ -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
}

View file

@ -101,8 +101,12 @@ func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager {
rm.maxConcurrent = cfg.MaxConcurrentSearches
}
if cfg.ClawHub.Enabled {
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
}

View file

@ -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

View file

@ -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
}

View file

@ -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)
}

View file

@ -11,6 +11,7 @@ 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
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),

196
pkg/tools/preview.go Normal file
View file

@ -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)
}

61
pkg/tools/preview_test.go Normal file
View file

@ -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)
}
}

View file

@ -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,13 +381,16 @@ func (t *ExecTool) guardCommand(command, cwd string) string {
}
}
if !explicitlyAllowed {
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)"
}
}
}
if len(t.allowPatterns) > 0 {
allowed := false

View file

@ -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)

View file

@ -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 != "" {

139
pkg/tools/write_pdf.go Normal file
View file

@ -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))
}

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

@ -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}
}

View file

@ -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}}
}
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)
}
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
}
// 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)
}
// Fall back to any model-list entry that uses the groq/ protocol.
for _, mc := range cfg.ModelList {
if strings.HasPrefix(mc.Model, "groq/") && mc.APIKey != "" {
return NewGroqTranscriber(mc.APIKey)
}
}
if cfg == nil {
return nil
}
var chain []Transcriber
for _, mc := range cfg.ModelList {
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
}
}
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 ""
}
}

View file

@ -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"},
},
},
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")