Add memory system, dynamic tool loading, and fix logging issues

- Add MemoryStore for persistent long-term and daily notes
- Add dynamic tool summary generation in system prompt
- Fix YAML frontmatter parsing for nanobot skill format
- Add GetSummaries() method to ToolRegistry
- Fix DebugCF logging to use structured metadata
- Improve web_search and shell tool descriptions
This commit is contained in:
yinwm
2026-02-10 23:33:28 +08:00
parent 10442732b4
commit 21d60f63fc
8 changed files with 341 additions and 395 deletions

View File

@@ -102,7 +102,11 @@ func main() {
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace) 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 { switch subcommand {
case "list": case "list":
@@ -242,70 +246,6 @@ Information about user goes here.
- What the user wants to learn from AI - What the user wants to learn from AI
- Preferred interaction style - Preferred interaction style
- Areas of interest - 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 "IDENTITY.md": `# Identity
@@ -977,7 +917,11 @@ func skillsCmd() {
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace) 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 { switch subcommand {
case "list": case "list":
@@ -1023,7 +967,7 @@ func skillsHelp() {
} }
func skillsListCmd(loader *skills.SkillsLoader) { func skillsListCmd(loader *skills.SkillsLoader) {
allSkills := loader.ListSkills(false) allSkills := loader.ListSkills()
if len(allSkills) == 0 { if len(allSkills) == 0 {
fmt.Println("No skills installed.") fmt.Println("No skills installed.")
@@ -1033,17 +977,10 @@ func skillsListCmd(loader *skills.SkillsLoader) {
fmt.Println("\nInstalled Skills:") fmt.Println("\nInstalled Skills:")
fmt.Println("------------------") fmt.Println("------------------")
for _, skill := range allSkills { for _, skill := range allSkills {
status := "✓" fmt.Printf(" ✓ %s (%s)\n", skill.Name, skill.Source)
if !skill.Available {
status = "✗"
}
fmt.Printf(" %s %s (%s)\n", status, skill.Name, skill.Source)
if skill.Description != "" { if skill.Description != "" {
fmt.Printf(" %s\n", skill.Description) fmt.Printf(" %s\n", skill.Description)
} }
if !skill.Available {
fmt.Printf(" Missing: %s\n", skill.Missing)
}
} }
} }

View File

@@ -17,14 +17,29 @@ type ContextBuilder struct {
workspace string workspace string
skillsLoader *skills.SkillsLoader skillsLoader *skills.SkillsLoader
memory *MemoryStore memory *MemoryStore
toolsSummary func() []string // Function to get tool summaries dynamically
} }
func NewContextBuilder(workspace string) *ContextBuilder { func getGlobalConfigDir() string {
builtinSkillsDir := filepath.Join(filepath.Dir(workspace), "picoclaw", "skills") 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{ return &ContextBuilder{
workspace: workspace, workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, builtinSkillsDir), skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace), memory: NewMemoryStore(workspace),
toolsSummary: toolsSummaryFunc,
} }
} }
@@ -33,14 +48,12 @@ func (cb *ContextBuilder) getIdentity() string {
workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace)) workspacePath, _ := filepath.Abs(filepath.Join(cb.workspace))
runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version()) runtime := fmt.Sprintf("%s %s, Go %s", runtime.GOOS, runtime.GOARCH, runtime.Version())
// Build tools section dynamically
toolsSection := cb.buildToolsSection()
return fmt.Sprintf(`# picoclaw 🦞 return fmt.Sprintf(`# picoclaw 🦞
You are picoclaw, a helpful AI assistant. You have access to tools that allow you to: You are picoclaw, a helpful AI assistant.
- 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
## Current Time ## Current Time
%s %s
@@ -50,26 +63,36 @@ You are picoclaw, a helpful AI assistant. You have access to tools that allow yo
## Workspace ## Workspace
Your workspace is at: %s Your workspace is at: %s
- Memory files: %s/memory/MEMORY.md - Memory: %s/memory/MEMORY.md
- Daily notes: %s/memory/2006-01-02.md - Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Custom skills: %s/skills/{skill-name}/SKILL.md - Skills: %s/skills/{skill-name}/SKILL.md
## Weather Information %s
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.
Always be helpful, accurate, and concise. When using tools, explain what you're doing. Always be helpful, accurate, and concise. When using tools, explain what you're doing.
When remembering something, write to %s/memory/MEMORY.md`, 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 { func (cb *ContextBuilder) BuildSystemPrompt() string {
@@ -84,22 +107,12 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
parts = append(parts, bootstrapContent) parts = append(parts, bootstrapContent)
} }
// Skills - progressive loading // Skills - show summary, AI can read full content with read_file tool
// 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
skillsSummary := cb.skillsLoader.BuildSkillsSummary() skillsSummary := cb.skillsLoader.BuildSkillsSummary()
if skillsSummary != "" { if skillsSummary != "" {
parts = append(parts, fmt.Sprintf(`# Skills 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)) %s`, skillsSummary))
} }
@@ -119,9 +132,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
"AGENTS.md", "AGENTS.md",
"SOUL.md", "SOUL.md",
"USER.md", "USER.md",
"TOOLS.md",
"IDENTITY.md", "IDENTITY.md",
"MEMORY.md",
} }
var result string 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) systemPrompt += fmt.Sprintf("\n\n## Current Session\nChannel: %s\nChat ID: %s", channel, chatID)
} }
// Log system prompt for debugging // Log system prompt summary for debugging (debug mode only)
logger.InfoCF("agent", "System prompt built", logger.DebugCF("agent", "System prompt built",
map[string]interface{}{ map[string]interface{}{
"total_chars": len(systemPrompt), "total_chars": len(systemPrompt),
"total_lines": strings.Count(systemPrompt, "\n") + 1, "total_lines": strings.Count(systemPrompt, "\n") + 1,
"section_count": strings.Count(systemPrompt, "\n\n---\n\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 != "" { if summary != "" {
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + 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", Role: "assistant",
Content: content, Content: content,
} }
if len(toolCalls) > 0 { // Always add assistant message, whether or not it has tool calls
messages = append(messages, msg) messages = append(messages, msg)
}
return messages return messages
} }
func (cb *ContextBuilder) loadSkills() string { func (cb *ContextBuilder) loadSkills() string {
allSkills := cb.skillsLoader.ListSkills(true) allSkills := cb.skillsLoader.ListSkills()
if len(allSkills) == 0 { if len(allSkills) == 0 {
return "" return ""
} }
@@ -218,18 +237,13 @@ func (cb *ContextBuilder) loadSkills() string {
// GetSkillsInfo returns information about loaded skills. // GetSkillsInfo returns information about loaded skills.
func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} { func (cb *ContextBuilder) GetSkillsInfo() map[string]interface{} {
allSkills := cb.skillsLoader.ListSkills(true) allSkills := cb.skillsLoader.ListSkills()
skillNames := make([]string, 0, len(allSkills)) skillNames := make([]string, 0, len(allSkills))
availableCount := 0
for _, s := range allSkills { for _, s := range allSkills {
skillNames = append(skillNames, s.Name) skillNames = append(skillNames, s.Name)
if s.Available {
availableCount++
}
} }
return map[string]interface{}{ return map[string]interface{}{
"total": len(allSkills), "total": len(allSkills),
"available": availableCount, "names": skillNames,
"names": skillNames,
} }
} }

View File

@@ -78,7 +78,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
model: cfg.Agents.Defaults.Model, model: cfg.Agents.Defaults.Model,
maxIterations: cfg.Agents.Defaults.MaxToolIterations, maxIterations: cfg.Agents.Defaults.MaxToolIterations,
sessions: sessionsManager, sessions: sessionsManager,
contextBuilder: NewContextBuilder(workspace), contextBuilder: NewContextBuilder(workspace, func() []string { return toolsRegistry.GetSummaries() }),
tools: toolsRegistry, tools: toolsRegistry,
running: false, 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( messages := al.contextBuilder.BuildMessages(
history, al.sessions.GetHistory(msg.SessionKey),
summary,
msg.Content, msg.Content,
nil, nil,
msg.Channel, 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{}{ response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
@@ -331,11 +347,8 @@ func (al *AgentLoop) processSystemMessage(ctx context.Context, msg bus.InboundMe
} }
// Build messages with the announce content // Build messages with the announce content
history := al.sessions.GetHistory(sessionKey)
summary := al.sessions.GetSummary(sessionKey)
messages := al.contextBuilder.BuildMessages( messages := al.contextBuilder.BuildMessages(
history, al.sessions.GetHistory(sessionKey),
summary,
msg.Content, msg.Content,
nil, nil,
originChannel, 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{}{ response, err := al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
@@ -462,104 +495,64 @@ func (al *AgentLoop) GetStartupInfo() map[string]interface{} {
return info return info
} }
func (al *AgentLoop) summarizeSession(sessionKey string) { // formatMessagesForLog formats messages for logging
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) func formatMessagesForLog(messages []providers.Message) string {
defer cancel() if len(messages) == 0 {
return "[]"
history := al.sessions.GetHistory(sessionKey)
summary := al.sessions.GetSummary(sessionKey)
// Keep last 4 messages for continuity
if len(history) <= 4 {
return
} }
toSummarize := history[:len(history)-4] var result string
result += "[\n"
// Oversized Message Guard (Dynamic) for i, msg := range messages {
// Skip messages larger than 50% of context window to prevent summarizer overflow. result += fmt.Sprintf(" [%d] Role: %s\n", i, msg.Role)
maxMessageTokens := al.contextWindow / 2 if msg.ToolCalls != nil && len(msg.ToolCalls) > 0 {
validMessages := make([]providers.Message, 0) result += " ToolCalls:\n"
omitted := false for _, tc := range msg.ToolCalls {
result += fmt.Sprintf(" - ID: %s, Type: %s, Name: %s\n", tc.ID, tc.Type, tc.Name)
for _, m := range toSummarize { if tc.Function != nil {
if m.Role != "user" && m.Role != "assistant" { result += fmt.Sprintf(" Arguments: %s\n", truncateString(tc.Function.Arguments, 200))
continue }
}
} }
// Estimate tokens for this message if msg.Content != "" {
msgTokens := len(m.Content) / 4 content := truncateString(msg.Content, 200)
if msgTokens > maxMessageTokens { result += fmt.Sprintf(" Content: %s\n", content)
omitted = true
continue
} }
validMessages = append(validMessages, m) if msg.ToolCallID != "" {
} result += fmt.Sprintf(" ToolCallID: %s\n", msg.ToolCallID)
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
} }
} else { result += "\n"
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 += "]"
return result
} }
func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Message, existingSummary string) (string, error) { // formatToolsForLog formats tool definitions for logging
prompt := "Provide a concise summary of this conversation segment, preserving core context and key points.\n" func formatToolsForLog(tools []providers.ToolDefinition) string {
if existingSummary != "" { if len(tools) == 0 {
prompt += "Existing context: " + existingSummary + "\n" return "[]"
}
prompt += "\nCONVERSATION:\n"
for _, m := range batch {
prompt += fmt.Sprintf("%s: %s\n", m.Role, m.Content)
} }
response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{ var result string
"max_tokens": 1024, result += "[\n"
"temperature": 0.3, for i, tool := range tools {
}) result += fmt.Sprintf(" [%d] Type: %s, Name: %s\n", i, tool.Type, tool.Function.Name)
if err != nil { result += fmt.Sprintf(" Description: %s\n", tool.Function.Description)
return "", err 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 { // truncateString truncates a string to max length
total := 0 func truncateString(s string, maxLen int) string {
for _, m := range messages { if len(s) <= maxLen {
total += len(m.Content) / 4 // Simple heuristic: 4 chars per token return s
} }
return total if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
} }

View File

@@ -10,12 +10,12 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"time" "time"
) )
// MemoryStore manages persistent memory for the agent. // 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 { type MemoryStore struct {
workspace string workspace string
memoryDir string memoryDir string
@@ -38,23 +38,29 @@ func NewMemoryStore(workspace string) *MemoryStore {
} }
} }
// getMemoryDir returns the memory directory path. // getTodayFile returns the path to today's daily note file (memory/YYYYMM/YYYYMMDD.md).
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).
func (ms *MemoryStore) getTodayFile() string { func (ms *MemoryStore) getTodayFile() string {
today := time.Now().Format("2006-01-02") today := time.Now().Format("20060102") // YYYYMMDD
return filepath.Join(ms.memoryDir, today+".md") 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. // Returns empty string if the file doesn't exist.
func (ms *MemoryStore) ReadToday() string { func (ms *MemoryStore) ReadToday() string {
todayFile := ms.getTodayFile() todayFile := ms.getTodayFile()
@@ -64,11 +70,15 @@ func (ms *MemoryStore) ReadToday() string {
return "" 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. // If the file doesn't exist, it creates a new file with a date header.
func (ms *MemoryStore) AppendToday(content string) error { func (ms *MemoryStore) AppendToday(content string) error {
todayFile := ms.getTodayFile() todayFile := ms.getTodayFile()
// Ensure month directory exists
monthDir := filepath.Dir(todayFile)
os.MkdirAll(monthDir, 0755)
var existingContent string var existingContent string
if data, err := os.ReadFile(todayFile); err == nil { if data, err := os.ReadFile(todayFile); err == nil {
existingContent = string(data) existingContent = string(data)
@@ -87,46 +97,39 @@ func (ms *MemoryStore) AppendToday(content string) error {
return os.WriteFile(todayFile, []byte(newContent), 0644) return os.WriteFile(todayFile, []byte(newContent), 0644)
} }
// ReadLongTerm reads the long-term memory (MEMORY.md). // GetRecentDailyNotes returns daily notes from the last N days.
// 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.
// Contents are joined with "---" separator. // Contents are joined with "---" separator.
func (ms *MemoryStore) GetRecentMemories(days int) string { func (ms *MemoryStore) GetRecentDailyNotes(days int) string {
var memories []string var notes []string
for i := 0; i < days; i++ { for i := 0; i < days; i++ {
date := time.Now().AddDate(0, 0, -i) date := time.Now().AddDate(0, 0, -i)
dateStr := date.Format("2006-01-02") dateStr := date.Format("20060102") // YYYYMMDD
filePath := filepath.Join(ms.memoryDir, dateStr+".md") monthDir := dateStr[:6] // YYYYMM
filePath := filepath.Join(ms.memoryDir, monthDir, dateStr+".md")
if data, err := os.ReadFile(filePath); err == nil { 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 ""
} }
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. // GetMemoryContext returns formatted memory context for the agent prompt.
// It includes long-term memory and today's notes sections if they exist. // Includes long-term memory and recent daily notes.
// Returns empty string if no memory exists.
func (ms *MemoryStore) GetMemoryContext() string { func (ms *MemoryStore) GetMemoryContext() string {
var parts []string var parts []string
@@ -136,15 +139,23 @@ func (ms *MemoryStore) GetMemoryContext() string {
parts = append(parts, "## Long-term Memory\n\n"+longTerm) parts = append(parts, "## Long-term Memory\n\n"+longTerm)
} }
// Today's notes // Recent daily notes (last 3 days)
today := ms.ReadToday() recentNotes := ms.GetRecentDailyNotes(3)
if today != "" { if recentNotes != "" {
parts = append(parts, "## Today's Notes\n\n"+today) parts = append(parts, "## Recent Daily Notes\n\n"+recentNotes)
} }
if len(parts) == 0 { if len(parts) == 0 {
return "" 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)
} }

View File

@@ -4,22 +4,14 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
) )
type SkillMetadata struct { type SkillMetadata struct {
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"` Description string `json:"description"`
Always bool `json:"always"`
Requires *SkillRequirements `json:"requires,omitempty"`
}
type SkillRequirements struct {
Bins []string `json:"bins"`
Env []string `json:"env"`
} }
type SkillInfo struct { type SkillInfo struct {
@@ -27,25 +19,25 @@ type SkillInfo struct {
Path string `json:"path"` Path string `json:"path"`
Source string `json:"source"` Source string `json:"source"`
Description string `json:"description"` Description string `json:"description"`
Available bool `json:"available"`
Missing string `json:"missing,omitempty"`
} }
type SkillsLoader struct { type SkillsLoader struct {
workspace string workspace string
workspaceSkills string workspaceSkills string // workspace skills (项目级别)
builtinSkills string 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{ return &SkillsLoader{
workspace: workspace, workspace: workspace,
workspaceSkills: filepath.Join(workspace, "skills"), workspaceSkills: filepath.Join(workspace, "skills"),
globalSkills: globalSkills, // ~/.picoclaw/skills
builtinSkills: builtinSkills, builtinSkills: builtinSkills,
} }
} }
func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo { func (sl *SkillsLoader) ListSkills() []SkillInfo {
skills := make([]SkillInfo, 0) skills := make([]SkillInfo, 0)
if sl.workspaceSkills != "" { if sl.workspaceSkills != "" {
@@ -62,12 +54,41 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
metadata := sl.getSkillMetadata(skillFile) metadata := sl.getSkillMetadata(skillFile)
if metadata != nil { if metadata != nil {
info.Description = metadata.Description info.Description = metadata.Description
info.Available = sl.checkRequirements(metadata.Requires) }
if !info.Available { skills = append(skills, info)
info.Missing = sl.getMissingRequirements(metadata.Requires) }
}
}
}
}
// 全局 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) skills = append(skills, info)
} }
@@ -82,9 +103,10 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
if dir.IsDir() { if dir.IsDir() {
skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md") skillFile := filepath.Join(sl.builtinSkills, dir.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); err == nil { if _, err := os.Stat(skillFile); err == nil {
// 检查是否已被 workspace 或 global skills 覆盖
exists := false exists := false
for _, s := range skills { 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 exists = true
break break
} }
@@ -101,12 +123,6 @@ func (sl *SkillsLoader) ListSkills(filterUnavailable bool) []SkillInfo {
metadata := sl.getSkillMetadata(skillFile) metadata := sl.getSkillMetadata(skillFile)
if metadata != nil { if metadata != nil {
info.Description = metadata.Description 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) 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 return skills
} }
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) { func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
// 1. 优先从 workspace skills 加载(项目级别)
if sl.workspaceSkills != "" { if sl.workspaceSkills != "" {
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md") skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil { 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 != "" { if sl.builtinSkills != "" {
skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md") skillFile := filepath.Join(sl.builtinSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil { if content, err := os.ReadFile(skillFile); err == nil {
@@ -163,7 +179,7 @@ func (sl *SkillsLoader) LoadSkillsForContext(skillNames []string) string {
} }
func (sl *SkillsLoader) BuildSkillsSummary() string { func (sl *SkillsLoader) BuildSkillsSummary() string {
allSkills := sl.ListSkills(false) allSkills := sl.ListSkills()
if len(allSkills) == 0 { if len(allSkills) == 0 {
return "" return ""
} }
@@ -175,21 +191,11 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
escapedDesc := escapeXML(s.Description) escapedDesc := escapeXML(s.Description)
escapedPath := escapeXML(s.Path) escapedPath := escapeXML(s.Path)
available := "true" lines = append(lines, fmt.Sprintf(" <skill>"))
if !s.Available {
available = "false"
}
lines = append(lines, fmt.Sprintf(" <skill available=\"%s\">", available))
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName)) lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc)) lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc))
lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath)) lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath))
lines = append(lines, fmt.Sprintf(" <source>%s</source>", s.Source))
if !s.Available && s.Missing != "" {
escapedMissing := escapeXML(s.Missing)
lines = append(lines, fmt.Sprintf(" <requires>%s</requires>", escapedMissing))
}
lines = append(lines, " </skill>") lines = append(lines, " </skill>")
} }
lines = append(lines, "</skills>") lines = append(lines, "</skills>")
@@ -197,18 +203,6 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
return strings.Join(lines, "\n") 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 { func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
content, err := os.ReadFile(skillPath) content, err := os.ReadFile(skillPath)
if err != nil { if err != nil {
@@ -222,27 +216,54 @@ func (sl *SkillsLoader) getSkillMetadata(skillPath string) *SkillMetadata {
} }
} }
var metadata struct { // Try JSON first (for backward compatibility)
Name string `json:"name"` var jsonMeta struct {
Description string `json:"description"` Name string `json:"name"`
Always bool `json:"always"` Description string `json:"description"`
Requires *SkillRequirements `json:"requires"` }
} if err := json.Unmarshal([]byte(frontmatter), &jsonMeta); err == nil {
return &SkillMetadata{
if err := json.Unmarshal([]byte(frontmatter), &metadata); err != nil { Name: jsonMeta.Name,
return nil Description: jsonMeta.Description,
}
} }
// Fall back to simple YAML parsing
yamlMeta := sl.parseSimpleYAML(frontmatter)
return &SkillMetadata{ return &SkillMetadata{
Name: metadata.Name, Name: yamlMeta["name"],
Description: metadata.Description, Description: yamlMeta["description"],
Always: metadata.Always,
Requires: metadata.Requires,
} }
} }
// 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 { 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) match := re.FindStringSubmatch(content)
if len(match) > 1 { if len(match) > 1 {
return match[1] return match[1]
@@ -255,49 +276,6 @@ func (sl *SkillsLoader) stripFrontmatter(content string) string {
return re.ReplaceAllString(content, "") 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 { func escapeXML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;") s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;") s = strings.ReplaceAll(s, "<", "&lt;")

View File

@@ -101,3 +101,16 @@ func (r *ToolRegistry) Count() int {
defer r.mu.RUnlock() defer r.mu.RUnlock()
return len(r.tools) 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
}

View File

@@ -25,9 +25,9 @@ func NewExecTool(workingDir string) *ExecTool {
regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
regexp.MustCompile(`\bdel\s+/[fq]\b`), regexp.MustCompile(`\bdel\s+/[fq]\b`),
regexp.MustCompile(`\brmdir\s+/s\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(`\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(`\b(shutdown|reboot|poweroff)\b`),
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
} }

View File

@@ -36,7 +36,7 @@ func (t *WebSearchTool) Name() string {
} }
func (t *WebSearchTool) Description() 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{} { func (t *WebSearchTool) Parameters() map[string]interface{} {