diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index f5b5135..751cdda 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -102,7 +102,11 @@ func main() { workspace := cfg.WorkspacePath() installer := skills.NewSkillInstaller(workspace) - skillsLoader := skills.NewSkillsLoader(workspace, "") + // 获取全局配置目录和内置 skills 目录 + globalDir := filepath.Dir(getConfigPath()) + globalSkillsDir := filepath.Join(globalDir, "skills") + builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") + skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) switch subcommand { case "list": @@ -242,70 +246,6 @@ Information about user goes here. - What the user wants to learn from AI - Preferred interaction style - Areas of interest -`, - "TOOLS.md": `# Available Tools - -This document describes the tools available to picoclaw. - -## File Operations - -### Read Files -- Read file contents -- Supports text, markdown, code files - -### Write Files -- Create new files -- Overwrite existing files -- Supports various formats - -### List Directories -- List directory contents -- Recursive listing support - -### Edit Files -- Make specific edits to files -- Line-by-line editing -- String replacement - -## Web Tools - -### Web Search -- Search the internet using search API -- Returns titles, URLs, snippets -- Optional: Requires API key for best results - -### Web Fetch -- Fetch specific URLs -- Extract readable content -- Supports HTML, JSON, plain text -- Automatic content extraction - -## Command Execution - -### Shell Commands -- Execute any shell command -- Run in workspace directory -- Full shell access with timeout protection - -## Messaging - -### Send Messages -- Send messages to chat channels -- Supports Telegram, WhatsApp, Feishu -- Used for notifications and responses - -## AI Capabilities - -### Context Building -- Load system instructions from files -- Load skills dynamically -- Build conversation history -- Include timezone and other context - -### Memory Management -- Long-term memory via MEMORY.md -- Daily notes via dated files -- Persistent across sessions `, "IDENTITY.md": `# Identity @@ -977,7 +917,11 @@ func skillsCmd() { workspace := cfg.WorkspacePath() installer := skills.NewSkillInstaller(workspace) - skillsLoader := skills.NewSkillsLoader(workspace, "") + // 获取全局配置目录和内置 skills 目录 + globalDir := filepath.Dir(getConfigPath()) + globalSkillsDir := filepath.Join(globalDir, "skills") + builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") + skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) switch subcommand { case "list": @@ -1023,7 +967,7 @@ func skillsHelp() { } func skillsListCmd(loader *skills.SkillsLoader) { - allSkills := loader.ListSkills(false) + allSkills := loader.ListSkills() if len(allSkills) == 0 { fmt.Println("No skills installed.") @@ -1033,17 +977,10 @@ func skillsListCmd(loader *skills.SkillsLoader) { fmt.Println("\nInstalled Skills:") fmt.Println("------------------") for _, skill := range allSkills { - status := "✓" - if !skill.Available { - status = "✗" - } - fmt.Printf(" %s %s (%s)\n", status, skill.Name, skill.Source) + fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source) if skill.Description != "" { fmt.Printf(" %s\n", skill.Description) } - if !skill.Available { - fmt.Printf(" Missing: %s\n", skill.Missing) - } } } diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 0870a23..506e5dc 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -17,14 +17,29 @@ type ContextBuilder struct { workspace string skillsLoader *skills.SkillsLoader memory *MemoryStore + toolsSummary func() []string // Function to get tool summaries dynamically } -func NewContextBuilder(workspace string) *ContextBuilder { - builtinSkillsDir := filepath.Join(filepath.Dir(workspace), "picoclaw", "skills") +func getGlobalConfigDir() string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".picoclaw") +} + +func NewContextBuilder(workspace string, toolsSummaryFunc func() []string) *ContextBuilder { + // builtin skills: 当前项目的 skills 目录 + // 使用当前工作目录下的 skills/ 目录 + wd, _ := os.Getwd() + builtinSkillsDir := filepath.Join(wd, "skills") + globalSkillsDir := filepath.Join(getGlobalConfigDir(), "skills") + return &ContextBuilder{ workspace: workspace, - skillsLoader: skills.NewSkillsLoader(workspace, builtinSkillsDir), + skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir), memory: NewMemoryStore(workspace), + toolsSummary: toolsSummaryFunc, } } @@ -33,14 +48,12 @@ func (cb *ContextBuilder) getIdentity() string { workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) + // Build tools section dynamically + toolsSection := cb.buildToolsSection() + return fmt.Sprintf(`# picoclaw 🦞 -You are picoclaw, a helpful AI assistant. You have access to tools that allow you to: -- Read, write, and edit files -- Execute shell commands -- Search the web and fetch web pages -- Send messages to users on chat channels -- Spawn subagents for complex background tasks +You are picoclaw, a helpful AI assistant. ## Current Time %s @@ -50,26 +63,36 @@ You are picoclaw, a helpful AI assistant. You have access to tools that allow yo ## Workspace Your workspace is at: %s -- Memory files: %s/memory/MEMORY.md -- Daily notes: %s/memory/2006-01-02.md -- Custom skills: %s/skills/{skill-name}/SKILL.md +- Memory: %s/memory/MEMORY.md +- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md +- Skills: %s/skills/{skill-name}/SKILL.md -## Weather Information -When users ask about weather, use the web_fetch tool with wttr.in URLs: -- Current weather: https://wttr.in/{city}?format=j1 -- Beijing: https://wttr.in/Beijing?format=j1 -- Shanghai: https://wttr.in/Shanghai?format=j1 -- New York: https://wttr.in/New_York?format=j1 -- London: https://wttr.in/London?format=j1 -- Tokyo: https://wttr.in/Tokyo?format=j1 - -IMPORTANT: When responding to direct questions or conversations, reply directly with your text response. -Only use the 'message' tool when you need to send a message to a specific chat channel (like WhatsApp). -For normal conversation, just respond with text - do not call the message tool. +%s Always be helpful, accurate, and concise. When using tools, explain what you're doing. When remembering something, write to %s/memory/MEMORY.md`, - now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath) + now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath) +} + +func (cb *ContextBuilder) buildToolsSection() string { + if cb.toolsSummary == nil { + return "" + } + + summaries := cb.toolsSummary() + if len(summaries) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString("## Available Tools\n\n") + sb.WriteString("You have access to the following tools:\n\n") + for _, s := range summaries { + sb.WriteString(s) + sb.WriteString("\n") + } + + return sb.String() } func (cb *ContextBuilder) BuildSystemPrompt() string { @@ -84,22 +107,12 @@ func (cb *ContextBuilder) BuildSystemPrompt() string { parts = append(parts, bootstrapContent) } - // Skills - progressive loading - // 1. Always skills: load full content - alwaysSkills := cb.skillsLoader.GetAlwaysSkills() - if len(alwaysSkills) > 0 { - alwaysContent := cb.skillsLoader.LoadSkillsForContext(alwaysSkills) - if alwaysContent != "" { - parts = append(parts, "# Active Skills\n\n"+alwaysContent) - } - } - - // 2. Available skills: only show summary + // Skills - show summary, AI can read full content with read_file tool skillsSummary := cb.skillsLoader.BuildSkillsSummary() if skillsSummary != "" { parts = append(parts, fmt.Sprintf(`# Skills -The following skills extend your capabilities. To use a skill, read its SKILL.md file. +The following skills extend your capabilities. To use a skill, read its SKILL.md file using the read_file tool. %s`, skillsSummary)) } @@ -119,9 +132,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string { "AGENTS.md", "SOUL.md", "USER.md", - "TOOLS.md", "IDENTITY.md", - "MEMORY.md", } var result string @@ -149,14 +160,23 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID) } - // Log system prompt for debugging - logger.InfoCF("agent", "System prompt built", + // Log system prompt summary for debugging (debug mode only) + logger.DebugCF("agent", "System prompt built", map[string]interface{}{ "total_chars": len(systemPrompt), "total_lines": strings.Count(systemPrompt, "\n") + 1, "section_count": strings.Count(systemPrompt, "\n\n---\n\n") + 1, }) - logger.DebugCF("agent", "Full system prompt:\n"+systemPrompt, nil) + + // Log preview of system prompt (avoid logging huge content) + preview := systemPrompt + if len(preview) > 500 { + preview = preview[:500] + "... (truncated)" + } + logger.DebugCF("agent", "System prompt preview", + map[string]interface{}{ + "preview": preview, + }) if summary != "" { systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary @@ -191,14 +211,13 @@ func (cb *ContextBuilder) AddAssistantMessage(messages []providers.Message, cont Role: "assistant", Content: content, } - if len(toolCalls) > 0 { - messages = append(messages, msg) - } + // Always add assistant message, whether or not it has tool calls + messages = append(messages, msg) return messages } func (cb *ContextBuilder) loadSkills() string { - allSkills := cb.skillsLoader.ListSkills(true) + allSkills := cb.skillsLoader.ListSkills() if len(allSkills) == 0 { return "" } @@ -218,18 +237,13 @@ func (cb *ContextBuilder) loadSkills() string { // GetSkillsInfo returns information about loaded skills. func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} { - allSkills := cb.skillsLoader.ListSkills(true) + allSkills := cb.skillsLoader.ListSkills() skillNames := make([]string, 0, len(allSkills)) - availableCount := 0 for _, s := range allSkills { skillNames = append(skillNames, s.Name) - if s.Available { - availableCount++ - } } return map[string]interface{}{ - "total": len(allSkills), - "available": availableCount, - "names": skillNames, + "total": len(allSkills), + "names": skillNames, } } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 79b3cb0..5737396 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -78,7 +78,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers model: cfg.Agents.Defaults.Model, maxIterations: cfg.Agents.Defaults.MaxToolIterations, sessions: sessionsManager, - contextBuilder: NewContextBuilder(workspace), + contextBuilder: NewContextBuilder(workspace, func() []string { return toolsRegistry.GetSummaries() }), tools: toolsRegistry, running: false, } @@ -159,12 +159,8 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) } } - history := al.sessions.GetHistory(msg.SessionKey) - summary := al.sessions.GetSummary(msg.SessionKey) - messages := al.contextBuilder.BuildMessages( - history, - summary, + al.sessions.GetHistory(msg.SessionKey), msg.Content, nil, msg.Channel, @@ -196,6 +192,26 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) }) } + // Log LLM request details + logger.DebugCF("agent", "LLM request", + map[string]interface{}{ + "iteration": iteration, + "model": al.model, + "messages_count": len(messages), + "tools_count": len(providerToolDefs), + "max_tokens": 8192, + "temperature": 0.7, + "system_prompt_len": len(messages[0].Content), + }) + + // Log full messages (detailed) + logger.DebugCF("agent", "Full LLM request", + map[string]interface{}{ + "iteration": iteration, + "messages_json": formatMessagesForLog(messages), + "tools_json": formatToolsForLog(providerToolDefs), + }) + response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{ "max_tokens": 8192, "temperature": 0.7, @@ -331,11 +347,8 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe } // Build messages with the announce content - history := al.sessions.GetHistory(sessionKey) - summary := al.sessions.GetSummary(sessionKey) messages := al.contextBuilder.BuildMessages( - history, - summary, + al.sessions.GetHistory(sessionKey), msg.Content, nil, originChannel, @@ -361,6 +374,26 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe }) } + // Log LLM request details + logger.DebugCF("agent", "LLM request", + map[string]interface{}{ + "iteration": iteration, + "model": al.model, + "messages_count": len(messages), + "tools_count": len(providerToolDefs), + "max_tokens": 8192, + "temperature": 0.7, + "system_prompt_len": len(messages[0].Content), + }) + + // Log full messages (detailed) + logger.DebugCF("agent", "Full LLM request", + map[string]interface{}{ + "iteration": iteration, + "messages_json": formatMessagesForLog(messages), + "tools_json": formatToolsForLog(providerToolDefs), + }) + response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{ "max_tokens": 8192, "temperature": 0.7, @@ -462,104 +495,64 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} { return info } -func (al *AgentLoop) summarizeSession(sessionKey string) { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - history := al.sessions.GetHistory(sessionKey) - summary := al.sessions.GetSummary(sessionKey) - - // Keep last 4 messages for continuity - if len(history) <= 4 { - return +// formatMessagesForLog formats messages for logging +func formatMessagesForLog(messages []providers.Message) string { + if len(messages) == 0 { + return "[]" } - toSummarize := history[:len(history)-4] - - // Oversized Message Guard (Dynamic) - // Skip messages larger than 50% of context window to prevent summarizer overflow. - maxMessageTokens := al.contextWindow / 2 - validMessages := make([]providers.Message, 0) - omitted := false - - for _, m := range toSummarize { - if m.Role != "user" && m.Role != "assistant" { - continue + var result string + result += "[\n" + for i, msg := range messages { + result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role) + if msg.ToolCalls != nil && len(msg.ToolCalls) > 0 { + result += " ToolCalls:\n" + for _, tc := range msg.ToolCalls { + result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name) + if tc.Function != nil { + result += fmt.Sprintf(" Arguments: %s\n", truncateString(tc.Function.Arguments, 200)) + } + } } - // Estimate tokens for this message - msgTokens := len(m.Content) / 4 - if msgTokens > maxMessageTokens { - omitted = true - continue + if msg.Content != "" { + content := truncateString(msg.Content, 200) + result += fmt.Sprintf(" Content: %s\n", content) } - validMessages = append(validMessages, m) - } - - if len(validMessages) == 0 { - return - } - - // Multi-Part Summarization - // Split into two parts if history is significant - var finalSummary string - if len(validMessages) > 10 { - mid := len(validMessages) / 2 - part1 := validMessages[:mid] - part2 := validMessages[mid:] - - s1, _ := al.summarizeBatch(ctx, part1, "") - s2, _ := al.summarizeBatch(ctx, part2, "") - - // Merge them - mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2) - resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, - }) - if err == nil { - finalSummary = resp.Content - } else { - finalSummary = s1 + " " + s2 + if msg.ToolCallID != "" { + result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID) } - } else { - finalSummary, _ = al.summarizeBatch(ctx, validMessages, summary) - } - - if omitted && finalSummary != "" { - finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" - } - - if finalSummary != "" { - al.sessions.SetSummary(sessionKey, finalSummary) - al.sessions.TruncateHistory(sessionKey, 4) - al.sessions.Save(al.sessions.GetOrCreate(sessionKey)) + result += "\n" } + result += "]" + return result } -func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Message, existingSummary string) (string, error) { - prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n" - if existingSummary != "" { - prompt += "Existing context: " + existingSummary + "\n" - } - prompt += "\nCONVERSATION:\n" - for _, m := range batch { - prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content) +// formatToolsForLog formats tool definitions for logging +func formatToolsForLog(tools []providers.ToolDefinition) string { + if len(tools) == 0 { + return "[]" } - response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, - }) - if err != nil { - return "", err + var result string + result += "[\n" + for i, tool := range tools { + result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name) + result += fmt.Sprintf(" Description: %s\n", tool.Function.Description) + if len(tool.Function.Parameters) > 0 { + result += fmt.Sprintf(" Parameters: %s\n", truncateString(fmt.Sprintf("%v", tool.Function.Parameters), 200)) + } } - return response.Content, nil + result += "]" + return result } -func (al *AgentLoop) estimateTokens(messages []providers.Message) int { - total := 0 - for _, m := range messages { - total += len(m.Content) / 4 // Simple heuristic: 4 chars per token +// truncateString truncates a string to max length +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s } - return total + if maxLen <= 3 { + return s[:maxLen] + } + return s[:maxLen-3] + "..." } diff --git a/pkg/agent/memory.go b/pkg/agent/memory.go index 4668685..f27882d 100644 --- a/pkg/agent/memory.go +++ b/pkg/agent/memory.go @@ -10,12 +10,12 @@ import ( "fmt" "os" "path/filepath" - "strings" "time" ) // MemoryStore manages persistent memory for the agent. -// Supports daily notes (memory/YYYY-MM-DD.md) and long-term memory (MEMORY.md). +// - Long-term memory: memory/MEMORY.md +// - Daily notes: memory/YYYYMM/YYYYMMDD.md type MemoryStore struct { workspace string memoryDir string @@ -38,23 +38,29 @@ func NewMemoryStore(workspace string) *MemoryStore { } } -// getMemoryDir returns the memory directory path. -func (ms *MemoryStore) getMemoryDir() string { - return ms.memoryDir -} - -// getMemoryFile returns the long-term memory file path. -func (ms *MemoryStore) getMemoryFile() string { - return ms.memoryFile -} - -// getTodayFile returns the path to today's memory file (YYYY-MM-DD.md). +// getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md). func (ms *MemoryStore) getTodayFile() string { - today := time.Now().Format("2006-01-02") - return filepath.Join(ms.memoryDir, today+".md") + today := time.Now().Format("20060102") // YYYYMMDD + monthDir := today[:6] // YYYYMM + filePath := filepath.Join(ms.memoryDir, monthDir, today+".md") + return filePath } -// ReadToday reads today's memory notes. +// ReadLongTerm reads the long-term memory (MEMORY.md). +// Returns empty string if the file doesn't exist. +func (ms *MemoryStore) ReadLongTerm() string { + if data, err := os.ReadFile(ms.memoryFile); err == nil { + return string(data) + } + return "" +} + +// WriteLongTerm writes content to the long-term memory file (MEMORY.md). +func (ms *MemoryStore) WriteLongTerm(content string) error { + return os.WriteFile(ms.memoryFile, []byte(content), 0644) +} + +// ReadToday reads today's daily note. // Returns empty string if the file doesn't exist. func (ms *MemoryStore) ReadToday() string { todayFile := ms.getTodayFile() @@ -64,11 +70,15 @@ func (ms *MemoryStore) ReadToday() string { return "" } -// AppendToday appends content to today's memory notes. +// AppendToday appends content to today's daily note. // If the file doesn't exist, it creates a new file with a date header. func (ms *MemoryStore) AppendToday(content string) error { todayFile := ms.getTodayFile() + // Ensure month directory exists + monthDir := filepath.Dir(todayFile) + os.MkdirAll(monthDir, 0755) + var existingContent string if data, err := os.ReadFile(todayFile); err == nil { existingContent = string(data) @@ -87,46 +97,39 @@ func (ms *MemoryStore) AppendToday(content string) error { return os.WriteFile(todayFile, []byte(newContent), 0644) } -// ReadLongTerm reads the long-term memory (MEMORY.md). -// Returns empty string if the file doesn't exist. -func (ms *MemoryStore) ReadLongTerm() string { - if data, err := os.ReadFile(ms.memoryFile); err == nil { - return string(data) - } - return "" -} - -// WriteLongTerm writes content to the long-term memory file (MEMORY.md). -func (ms *MemoryStore) WriteLongTerm(content string) error { - return os.WriteFile(ms.memoryFile, []byte(content), 0644) -} - -// GetRecentMemories returns memories from the last N days. -// It reads and combines the contents of memory files from the past days. +// GetRecentDailyNotes returns daily notes from the last N days. // Contents are joined with "---" separator. -func (ms *MemoryStore) GetRecentMemories(days int) string { - var memories []string +func (ms *MemoryStore) GetRecentDailyNotes(days int) string { + var notes []string for i := 0; i < days; i++ { date := time.Now().AddDate(0, 0, -i) - dateStr := date.Format("2006-01-02") - filePath := filepath.Join(ms.memoryDir, dateStr+".md") + dateStr := date.Format("20060102") // YYYYMMDD + monthDir := dateStr[:6] // YYYYMM + filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md") if data, err := os.ReadFile(filePath); err == nil { - memories = append(memories, string(data)) + notes = append(notes, string(data)) } } - if len(memories) == 0 { + if len(notes) == 0 { return "" } - return strings.Join(memories, "\n\n---\n\n") + // Join with separator + var result string + for i, note := range notes { + if i > 0 { + result += "\n\n---\n\n" + } + result += note + } + return result } // GetMemoryContext returns formatted memory context for the agent prompt. -// It includes long-term memory and today's notes sections if they exist. -// Returns empty string if no memory exists. +// Includes long-term memory and recent daily notes. func (ms *MemoryStore) GetMemoryContext() string { var parts []string @@ -136,15 +139,23 @@ func (ms *MemoryStore) GetMemoryContext() string { parts = append(parts, "## Long-term Memory\n\n"+longTerm) } - // Today's notes - today := ms.ReadToday() - if today != "" { - parts = append(parts, "## Today's Notes\n\n"+today) + // Recent daily notes (last 3 days) + recentNotes := ms.GetRecentDailyNotes(3) + if recentNotes != "" { + parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes) } if len(parts) == 0 { return "" } - return strings.Join(parts, "\n\n") + // Join parts with separator + var result string + for i, part := range parts { + if i > 0 { + result += "\n\n---\n\n" + } + result += part + } + return fmt.Sprintf("# Memory\n\n%s", result) } diff --git a/pkg/skills/loader.go b/pkg/skills/loader.go index d0c2195..1f952c1 100644 --- a/pkg/skills/loader.go +++ b/pkg/skills/loader.go @@ -4,22 +4,14 @@ import ( "encoding/json" "fmt" "os" - "os/exec" "path/filepath" "regexp" "strings" ) type SkillMetadata struct { - Name string `json:"name"` - Description string `json:"description"` - Always bool `json:"always"` - Requires *SkillRequirements `json:"requires,omitempty"` -} - -type SkillRequirements struct { - Bins []string `json:"bins"` - Env []string `json:"env"` + Name string `json:"name"` + Description string `json:"description"` } type SkillInfo struct { @@ -27,25 +19,25 @@ type SkillInfo struct { Path string `json:"path"` Source string `json:"source"` Description string `json:"description"` - Available bool `json:"available"` - Missing string `json:"missing,omitempty"` } type SkillsLoader struct { workspace string - workspaceSkills string - builtinSkills string + workspaceSkills string // workspace skills (项目级别) + globalSkills string // 全局 skills (~/.picoclaw/skills) + builtinSkills string // 内置 skills } -func NewSkillsLoader(workspace string, builtinSkills string) *SkillsLoader { +func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader { return &SkillsLoader{ workspace: workspace, workspaceSkills: filepath.Join(workspace, "skills"), + globalSkills: globalSkills, // ~/.picoclaw/skills builtinSkills: builtinSkills, } } -func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo { +func (sl *SkillsLoader) ListSkills() []SkillInfo { skills := make([]SkillInfo, 0) if sl.workspaceSkills != "" { @@ -62,12 +54,41 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo { metadata := sl.getSkillMetadata(skillFile) if metadata != nil { info.Description = metadata.Description - info.Available = sl.checkRequirements(metadata.Requires) - if !info.Available { - info.Missing = sl.getMissingRequirements(metadata.Requires) + } + skills = append(skills, info) + } + } + } + } + } + + // 全局 skills (~/.picoclaw/skills) - 被 workspace skills 覆盖 + if sl.globalSkills != "" { + if dirs, err := os.ReadDir(sl.globalSkills); err == nil { + for _, dir := range dirs { + if dir.IsDir() { + skillFile := filepath.Join(sl.globalSkills, dir.Name(), "SKILL.md") + if _, err := os.Stat(skillFile); err == nil { + // 检查是否已被 workspace skills 覆盖 + exists := false + for _, s := range skills { + if s.Name == dir.Name() && s.Source == "workspace" { + exists = true + break } - } else { - info.Available = true + } + if exists { + continue + } + + info := SkillInfo{ + Name: dir.Name(), + Path: skillFile, + Source: "global", + } + metadata := sl.getSkillMetadata(skillFile) + if metadata != nil { + info.Description = metadata.Description } skills = append(skills, info) } @@ -82,9 +103,10 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo { if dir.IsDir() { skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md") if _, err := os.Stat(skillFile); err == nil { + // 检查是否已被 workspace 或 global skills 覆盖 exists := false for _, s := range skills { - if s.Name == dir.Name() && s.Source == "workspace" { + if s.Name == dir.Name() && (s.Source == "workspace" || s.Source == "global") { exists = true break } @@ -101,12 +123,6 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo { metadata := sl.getSkillMetadata(skillFile) if metadata != nil { info.Description = metadata.Description - info.Available = sl.checkRequirements(metadata.Requires) - if !info.Available { - info.Missing = sl.getMissingRequirements(metadata.Requires) - } - } else { - info.Available = true } skills = append(skills, info) } @@ -115,20 +131,11 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo { } } - if filterUnavailable { - filtered := make([]SkillInfo, 0) - for _, s := range skills { - if s.Available { - filtered = append(filtered, s) - } - } - return filtered - } - return skills } func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { + // 1. 优先从 workspace skills 加载(项目级别) if sl.workspaceSkills != "" { skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { @@ -136,6 +143,15 @@ func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { } } + // 2. 其次从全局 skills 加载 (~/.picoclaw/skills) + if sl.globalSkills != "" { + skillFile := filepath.Join(sl.globalSkills, name, "SKILL.md") + if content, err := os.ReadFile(skillFile); err == nil { + return sl.stripFrontmatter(string(content)), true + } + } + + // 3. 最后从内置 skills 加载 if sl.builtinSkills != "" { skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md") if content, err := os.ReadFile(skillFile); err == nil { @@ -163,7 +179,7 @@ func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string { } func (sl *SkillsLoader) BuildSkillsSummary() string { - allSkills := sl.ListSkills(false) + allSkills := sl.ListSkills() if len(allSkills) == 0 { return "" } @@ -175,21 +191,11 @@ func (sl *SkillsLoader) BuildSkillsSummary() string { escapedDesc := escapeXML(s.Description) escapedPath := escapeXML(s.Path) - available := "true" - if !s.Available { - available = "false" - } - - lines = append(lines, fmt.Sprintf(" ", available)) + lines = append(lines, fmt.Sprintf(" ")) lines = append(lines, fmt.Sprintf(" %s", escapedName)) lines = append(lines, fmt.Sprintf(" %s", escapedDesc)) lines = append(lines, fmt.Sprintf(" %s", escapedPath)) - - if !s.Available && s.Missing != "" { - escapedMissing := escapeXML(s.Missing) - lines = append(lines, fmt.Sprintf(" %s", escapedMissing)) - } - + lines = append(lines, fmt.Sprintf(" %s", s.Source)) lines = append(lines, " ") } lines = append(lines, "") @@ -197,18 +203,6 @@ func (sl *SkillsLoader) BuildSkillsSummary() string { return strings.Join(lines, "\n") } -func (sl *SkillsLoader) GetAlwaysSkills() []string { - skills := sl.ListSkills(true) - var always []string - for _, s := range skills { - metadata := sl.getSkillMetadata(s.Path) - if metadata != nil && metadata.Always { - always = append(always, s.Name) - } - } - return always -} - func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { content, err := os.ReadFile(skillPath) if err != nil { @@ -222,27 +216,54 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata { } } - var metadata struct { - Name string `json:"name"` - Description string `json:"description"` - Always bool `json:"always"` - Requires *SkillRequirements `json:"requires"` - } - - if err := json.Unmarshal([]byte(frontmatter), &metadata); err != nil { - return nil + // Try JSON first (for backward compatibility) + var jsonMeta struct { + Name string `json:"name"` + Description string `json:"description"` + } + if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil { + return &SkillMetadata{ + Name: jsonMeta.Name, + Description: jsonMeta.Description, + } } + // Fall back to simple YAML parsing + yamlMeta := sl.parseSimpleYAML(frontmatter) return &SkillMetadata{ - Name: metadata.Name, - Description: metadata.Description, - Always: metadata.Always, - Requires: metadata.Requires, + Name: yamlMeta["name"], + Description: yamlMeta["description"], } } +// parseSimpleYAML parses simple key: value YAML format +// Example: name: github\n description: "..." +func (sl *SkillsLoader) parseSimpleYAML(content string) map[string]string { + result := make(map[string]string) + + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + // Remove quotes if present + value = strings.Trim(value, "\"'") + result[key] = value + } + } + + return result +} + func (sl *SkillsLoader) extractFrontmatter(content string) string { - re := regexp.MustCompile(`^---\n(.*?)\n---`) + // (?s) enables DOTALL mode so . matches newlines + // Match first ---, capture everything until next --- on its own line + re := regexp.MustCompile(`(?s)^---\n(.*)\n---`) match := re.FindStringSubmatch(content) if len(match) > 1 { return match[1] @@ -255,49 +276,6 @@ func (sl *SkillsLoader) stripFrontmatter(content string) string { return re.ReplaceAllString(content, "") } -func (sl *SkillsLoader) checkRequirements(requires *SkillRequirements) bool { - if requires == nil { - return true - } - - for _, bin := range requires.Bins { - if _, err := exec.LookPath(bin); err != nil { - continue - } else { - return true - } - } - - for _, env := range requires.Env { - if os.Getenv(env) == "" { - return false - } - } - - return true -} - -func (sl *SkillsLoader) getMissingRequirements(requires *SkillRequirements) string { - if requires == nil { - return "" - } - - var missing []string - for _, bin := range requires.Bins { - if _, err := exec.LookPath(bin); err != nil { - missing = append(missing, fmt.Sprintf("CLI: %s", bin)) - } - } - - for _, env := range requires.Env { - if os.Getenv(env) == "" { - missing = append(missing, fmt.Sprintf("ENV: %s", env)) - } - } - - return strings.Join(missing, ", ") -} - func escapeXML(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 04b6cf7..d181944 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -101,3 +101,16 @@ func (r *ToolRegistry) Count() int { defer r.mu.RUnlock() return len(r.tools) } + +// GetSummaries returns human-readable summaries of all registered tools. +// Returns a slice of "name - description" strings. +func (r *ToolRegistry) GetSummaries() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + summaries := make([]string, 0, len(r.tools)) + for _, tool := range r.tools { + summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description())) + } + return summaries +} diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 87f67a0..d8aea40 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -25,9 +25,9 @@ func NewExecTool(workingDir string) *ExecTool { regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), regexp.MustCompile(`\bdel\s+/[fq]\b`), regexp.MustCompile(`\brmdir\s+/s\b`), - regexp.MustCompile(`\b(format|mkfs|diskpart)\b`), + regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args) regexp.MustCompile(`\bdd\s+if=`), - regexp.MustCompile(`>\s*/dev/sd`), + regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), } diff --git a/pkg/tools/web.go b/pkg/tools/web.go index 119c88f..3a35968 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -36,7 +36,7 @@ func (t *WebSearchTool) Name() string { } func (t *WebSearchTool) Description() string { - return "Search the web. Returns titles, URLs, and snippets." + return "Search the web for current information. Returns titles, URLs, and snippets from search results." } func (t *WebSearchTool) Parameters() map[string]interface{} {