feat: add rate limiting, memory/skill tools, and state management

- Add rate limiter for tool calls and requests (pkg/agent/ratelimit.go)
- Add persistent memory tool (write/read long-term and daily notes)
- Add skill tool for listing and reading skills
- Add atomic state management (pkg/agent/state/)
- Refactor skills loader to use dataDir instead of workspace
- Remove skill installer (search/install from registry)
- Add rate_limits config section
- Update heartbeat service to use state manager for last channel
- Refactor provider creation and model name handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
KoheiYamashita 2026-02-17 22:22:43 +09:00
parent 461585dbd5
commit add20a4454
19 changed files with 819 additions and 330 deletions

View file

@ -156,31 +156,26 @@ func main() {
os.Exit(1)
}
workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace)
dataDir := cfg.DataPath()
// 获取全局配置目录和内置 skills 目录
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
skillsLoader := skills.NewSkillsLoader(dataDir, globalSkillsDir, builtinSkillsDir)
switch subcommand {
case "list":
skillsListCmd(skillsLoader)
case "install":
skillsInstallCmd(installer)
case "remove", "uninstall":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills remove <skill-name>")
return
}
skillsRemoveCmd(installer, os.Args[3])
skillsRemoveCmd(dataDir, os.Args[3])
case "install-builtin":
skillsInstallBuiltinCmd(workspace)
skillsInstallBuiltinCmd(dataDir)
case "list-builtin":
skillsListBuiltinCmd()
case "search":
skillsSearchCmd(installer)
case "show":
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills show <skill-name>")
@ -237,7 +232,9 @@ func onboard() {
}
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
dataDir := cfg.DataPath()
os.MkdirAll(workspace, 0755)
createWorkspaceTemplates(dataDir)
fmt.Printf("%s picoclaw is ready!\n", logo)
fmt.Println("\nNext steps:")
@ -562,10 +559,11 @@ func gatewayCmd() {
})
// Setup cron tool and service
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace)
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.DataPath(), cfg.Agents.Defaults.RestrictToWorkspace)
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
cfg.DataPath(),
cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled,
)
@ -647,7 +645,7 @@ func gatewayCmd() {
}
fmt.Println("✓ Heartbeat service started")
stateManager := state.NewManager(cfg.WorkspacePath())
stateManager := state.NewManager(cfg.DataPath())
deviceService := devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB,
@ -987,13 +985,13 @@ func getConfigPath() string {
return filepath.Join(home, ".picoclaw", "config.json")
}
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, dataDir string, restrict bool) *cron.CronService {
cronStorePath := filepath.Join(dataDir, "cron", "jobs.json")
// Create cron service
cronService := cron.NewCronService(cronStorePath, nil)
// Create and register CronTool
// Create and register CronTool (workspace is for ExecTool sandboxing)
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict)
agentLoop.RegisterTool(cronTool)
@ -1025,7 +1023,7 @@ func cronCmd() {
return
}
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
cronStorePath := filepath.Join(cfg.DataPath(), "cron", "jobs.json")
switch subcommand {
case "list":
@ -1227,16 +1225,16 @@ func cronEnableCmd(storePath string, disable bool) {
func skillsHelp() {
fmt.Println("\nSkills commands:")
fmt.Println(" list List installed skills")
fmt.Println(" install <repo> Install skill from GitHub")
fmt.Println(" install-builtin Install all builtin skills to workspace")
fmt.Println(" list-builtin List available builtin skills")
fmt.Println(" remove <name> Remove installed skill")
fmt.Println(" search Search available skills")
fmt.Println(" show <name> Show skill details")
fmt.Println()
fmt.Println("To install custom skills, place SKILL.md files manually in:")
fmt.Println(" ~/.picoclaw/data/skills/<skill-name>/SKILL.md")
fmt.Println()
fmt.Println("Examples:")
fmt.Println(" picoclaw skills list")
fmt.Println(" picoclaw skills install sipeed/picoclaw-skills/weather")
fmt.Println(" picoclaw skills install-builtin")
fmt.Println(" picoclaw skills list-builtin")
fmt.Println(" picoclaw skills remove weather")
@ -1260,31 +1258,16 @@ func skillsListCmd(loader *skills.SkillsLoader) {
}
}
func skillsInstallCmd(installer *skills.SkillInstaller) {
if len(os.Args) < 4 {
fmt.Println("Usage: picoclaw skills install <github-repo>")
fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather")
return
}
func skillsRemoveCmd(dataDir string, skillName string) {
skillDir := filepath.Join(dataDir, "skills", skillName)
repo := os.Args[3]
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 {
fmt.Printf("✗ Failed to install skill: %v\n", err)
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
fmt.Printf("✗ Skill '%s' not found\n", skillName)
os.Exit(1)
}
fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo))
}
func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
fmt.Printf("Removing skill '%s'...\n", skillName)
if err := installer.Uninstall(skillName); err != nil {
if err := os.RemoveAll(skillDir); err != nil {
fmt.Printf("✗ Failed to remove skill: %v\n", err)
os.Exit(1)
}
@ -1292,9 +1275,9 @@ func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) {
fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName)
}
func skillsInstallBuiltinCmd(workspace string) {
func skillsInstallBuiltinCmd(dataDir string) {
builtinSkillsDir := "./picoclaw/skills"
workspaceSkillsDir := filepath.Join(workspace, "skills")
skillsDir := filepath.Join(dataDir, "skills")
fmt.Printf("Copying builtin skills to workspace...\n")
@ -1307,19 +1290,19 @@ func skillsInstallBuiltinCmd(workspace string) {
for _, skillName := range skillsToInstall {
builtinPath := filepath.Join(builtinSkillsDir, skillName)
workspacePath := filepath.Join(workspaceSkillsDir, skillName)
destPath := filepath.Join(skillsDir, skillName)
if _, err := os.Stat(builtinPath); err != nil {
fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err)
continue
}
if err := os.MkdirAll(workspacePath, 0755); err != nil {
if err := os.MkdirAll(destPath, 0755); err != nil {
fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err)
continue
}
if err := copyDirectory(builtinPath, workspacePath); err != nil {
if err := copyDirectory(builtinPath, destPath); err != nil {
fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err)
}
}
@ -1380,39 +1363,6 @@ func skillsListBuiltinCmd() {
}
}
func skillsSearchCmd(installer *skills.SkillInstaller) {
fmt.Println("Searching for available skills...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
availableSkills, err := installer.ListAvailableSkills(ctx)
if err != nil {
fmt.Printf("✗ Failed to fetch skills list: %v\n", err)
return
}
if len(availableSkills) == 0 {
fmt.Println("No skills available.")
return
}
fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills))
fmt.Println("--------------------")
for _, skill := range availableSkills {
fmt.Printf(" 📦 %s\n", skill.Name)
fmt.Printf(" %s\n", skill.Description)
fmt.Printf(" Repo: %s\n", skill.Repository)
if skill.Author != "" {
fmt.Printf(" Author: %s\n", skill.Author)
}
if len(skill.Tags) > 0 {
fmt.Printf(" Tags: %v\n", skill.Tags)
}
fmt.Println()
}
}
func skillsShowCmd(loader *skills.SkillsLoader, skillName string) {
content, ok := loader.LoadSkill(skillName)
if !ok {

View file

@ -23,8 +23,8 @@
},
"maixcam": {
"enabled": false,
"host": "0.0.0.0",
"port": 18790,
"host": "127.0.0.1",
"port": 18792,
"allow_from": []
},
"whatsapp": {
@ -56,7 +56,7 @@
"enabled": false,
"channel_secret": "YOUR_LINE_CHANNEL_SECRET",
"channel_access_token": "YOUR_LINE_CHANNEL_ACCESS_TOKEN",
"webhook_host": "0.0.0.0",
"webhook_host": "127.0.0.1",
"webhook_port": 18791,
"webhook_path": "/webhook/line",
"allow_from": []
@ -139,7 +139,11 @@
"monitor_usb": true
},
"gateway": {
"host": "0.0.0.0",
"host": "127.0.0.1",
"port": 18790
},
"rate_limits": {
"max_tool_calls_per_minute": 60,
"max_requests_per_minute": 30
}
}

View file

@ -16,6 +16,7 @@ import (
type ContextBuilder struct {
workspace string
dataDir string
skillsLoader *skills.SkillsLoader
memory *MemoryStore
tools *tools.ToolRegistry // Direct reference to tool registry
@ -29,7 +30,7 @@ func getGlobalConfigDir() string {
return filepath.Join(home, ".picoclaw")
}
func NewContextBuilder(workspace string) *ContextBuilder {
func NewContextBuilder(workspace string, dataDir string) *ContextBuilder {
// builtin skills: skills directory in current project
// Use the skills/ directory under the current working directory
wd, _ := os.Getwd()
@ -38,11 +39,22 @@ func NewContextBuilder(workspace string) *ContextBuilder {
return &ContextBuilder{
workspace: workspace,
skillsLoader: skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(workspace),
dataDir: dataDir,
skillsLoader: skills.NewSkillsLoader(dataDir, globalSkillsDir, builtinSkillsDir),
memory: NewMemoryStore(dataDir),
}
}
// GetMemory returns the memory store for tool registration.
func (cb *ContextBuilder) GetMemory() *MemoryStore {
return cb.memory
}
// GetSkillsLoader returns the skills loader for tool registration.
func (cb *ContextBuilder) GetSkillsLoader() *skills.SkillsLoader {
return cb.skillsLoader
}
// SetToolsRegistry sets the tools registry for dynamic tool summary generation.
func (cb *ContextBuilder) SetToolsRegistry(registry *tools.ToolRegistry) {
cb.tools = registry
@ -68,9 +80,6 @@ You are picoclaw, a helpful AI assistant.
## Workspace
Your workspace is at: %s
- Memory: %s/memory/MEMORY.md
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Skills: %s/skills/{skill-name}/SKILL.md
%s
@ -80,8 +89,12 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
3. **Memory** - Use the memory tool to store and retrieve information.
- write_long_term: Save important, date-independent facts (user preferences, project info, permanent notes)
- append_daily: Record today's events and memos (diary-like daily entries)
- read_long_term: Read long-term memory
- read_daily: Read today's daily notes`,
now, runtime, workspacePath, toolsSection)
}
func (cb *ContextBuilder) buildToolsSection() string {
@ -118,12 +131,12 @@ func (cb *ContextBuilder) BuildSystemPrompt() string {
parts = append(parts, bootstrapContent)
}
// Skills - show summary, AI can read full content with read_file tool
// Skills - show summary, AI can read full content with skill_read 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 using the read_file tool.
The following skills extend your capabilities. To use a skill, call the skill_read tool with the skill name.
%s`, skillsSummary))
}
@ -148,7 +161,7 @@ func (cb *ContextBuilder) LoadBootstrapFiles() string {
var result string
for _, filename := range bootstrapFiles {
filePath := filepath.Join(cb.workspace, filename)
filePath := filepath.Join(cb.dataDir, filename)
if data, err := os.ReadFile(filePath); err == nil {
result += fmt.Sprintf("## %s\n\n%s\n\n", filename, string(data))
}

View file

@ -44,6 +44,7 @@ type AgentLoop struct {
running atomic.Bool
summarizing sync.Map // Tracks which sessions are currently being summarized
channelManager *channels.Manager
rateLimiter *rateLimiter
}
// processOptions configures how a message is processed
@ -112,7 +113,9 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
workspace := cfg.WorkspacePath()
dataDir := cfg.DataPath()
os.MkdirAll(workspace, 0755)
os.MkdirAll(dataDir, 0755)
restrict := cfg.Agents.Defaults.RestrictToWorkspace
@ -133,15 +136,25 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
subagentTool := tools.NewSubagentTool(subagentManager)
toolsRegistry.Register(subagentTool)
sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions"))
// Use dataDir for sessions and state (outside workspace for security)
sessionsManager := session.NewSessionManager(filepath.Join(dataDir, "sessions"))
// Create state manager for atomic state persistence
stateManager := state.NewManager(workspace)
stateManager := state.NewManager(dataDir)
// Create context builder and set tools registry
contextBuilder := NewContextBuilder(workspace)
contextBuilder := NewContextBuilder(workspace, dataDir)
contextBuilder.SetToolsRegistry(toolsRegistry)
// Register memory and skill tools (controlled access to dataDir)
memoryTool := tools.NewMemoryTool(contextBuilder.GetMemory())
toolsRegistry.Register(memoryTool)
subagentTools.Register(memoryTool)
skillTool := tools.NewSkillTool(contextBuilder.GetSkillsLoader())
toolsRegistry.Register(skillTool)
subagentTools.Register(skillTool)
return &AgentLoop{
bus: msgBus,
provider: provider,
@ -154,6 +167,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
contextBuilder: contextBuilder,
tools: toolsRegistry,
summarizing: sync.Map{},
rateLimiter: newRateLimiter(cfg.RateLimits.MaxToolCallsPerMinute, cfg.RateLimits.MaxRequestsPerMinute),
}
}
@ -275,6 +289,16 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage)
return al.processSystemMessage(ctx, msg)
}
// Check request rate limit
if err := al.rateLimiter.checkRequest(); err != nil {
logger.WarnCF("agent", "Request rate limited",
map[string]interface{}{
"channel": msg.Channel,
"sender_id": msg.SenderID,
})
return fmt.Sprintf("Rate limited: %v. Please try again later.", err), nil
}
// Check for commands
if response, handled := al.handleCommand(ctx, msg); handled {
return response, nil
@ -643,6 +667,23 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
// Execute tool calls
for _, tc := range response.ToolCalls {
// Check tool call rate limit
if err := al.rateLimiter.checkToolCall(); err != nil {
logger.WarnCF("agent", "Tool call rate limited",
map[string]interface{}{
"tool": tc.Name,
"iteration": iteration,
})
toolResultMsg := providers.Message{
Role: "tool",
Content: fmt.Sprintf("Rate limited: %v", err),
ToolCallID: tc.ID,
}
messages = append(messages, toolResultMsg)
al.sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
continue
}
// Log tool call with arguments preview
argsJSON, _ := json.Marshal(tc.Arguments)
argsPreview := utils.Truncate(string(argsJSON), 200)

View file

@ -41,6 +41,7 @@ func TestRecordLastChannel(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -86,6 +87,7 @@ func TestRecordLastChatID(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -131,6 +133,7 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -167,6 +170,7 @@ func TestToolRegistry_ToolRegistration(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -213,6 +217,7 @@ func TestToolContext_Updates(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -244,6 +249,7 @@ func TestToolRegistry_GetDefinitions(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -288,6 +294,7 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -335,6 +342,7 @@ func TestCreateToolRegistry_ExecDisabled(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -374,6 +382,7 @@ func TestCreateToolRegistry_ExecEnabled(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -418,6 +427,7 @@ func TestCreateToolRegistry_I2CDisabled(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -455,6 +465,7 @@ func TestCreateToolRegistry_I2CEnabled(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -497,6 +508,7 @@ func TestCreateToolRegistry_SPIDisabled(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -534,6 +546,7 @@ func TestCreateToolRegistry_SPIEnabled(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -576,6 +589,7 @@ func TestAgentLoop_Stop(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -698,6 +712,7 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -740,6 +755,7 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
@ -805,6 +821,7 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
DataDir: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,

View file

@ -17,22 +17,22 @@ import (
// - Long-term memory: memory/MEMORY.md
// - Daily notes: memory/YYYYMM/YYYYMMDD.md
type MemoryStore struct {
workspace string
dataDir string
memoryDir string
memoryFile string
}
// NewMemoryStore creates a new MemoryStore with the given workspace path.
// NewMemoryStore creates a new MemoryStore with the given data directory path.
// It ensures the memory directory exists.
func NewMemoryStore(workspace string) *MemoryStore {
memoryDir := filepath.Join(workspace, "memory")
func NewMemoryStore(dataDir string) *MemoryStore {
memoryDir := filepath.Join(dataDir, "memory")
memoryFile := filepath.Join(memoryDir, "MEMORY.md")
// Ensure memory directory exists
os.MkdirAll(memoryDir, 0755)
return &MemoryStore{
workspace: workspace,
dataDir: dataDir,
memoryDir: memoryDir,
memoryFile: memoryFile,
}

84
pkg/agent/ratelimit.go Normal file
View file

@ -0,0 +1,84 @@
package agent
import (
"fmt"
"sync"
"time"
)
// rateLimiter provides simple sliding-window rate limiting for tool calls and requests.
type rateLimiter struct {
maxToolCallsPerMinute int
maxRequestsPerMinute int
mu sync.Mutex
toolCallTimes []time.Time
requestTimes []time.Time
}
func newRateLimiter(maxToolCalls, maxRequests int) *rateLimiter {
return &rateLimiter{
maxToolCallsPerMinute: maxToolCalls,
maxRequestsPerMinute: maxRequests,
}
}
// checkToolCall checks if a tool call is allowed under the rate limit.
// Returns nil if allowed, error if rate limited.
func (rl *rateLimiter) checkToolCall() error {
if rl.maxToolCallsPerMinute <= 0 {
return nil
}
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-time.Minute)
// Remove expired entries
rl.toolCallTimes = pruneOld(rl.toolCallTimes, cutoff)
if len(rl.toolCallTimes) >= rl.maxToolCallsPerMinute {
return fmt.Errorf("tool call limit exceeded (%d/min)", rl.maxToolCallsPerMinute)
}
rl.toolCallTimes = append(rl.toolCallTimes, now)
return nil
}
// checkRequest checks if a request is allowed under the rate limit.
// Returns nil if allowed, error if rate limited.
func (rl *rateLimiter) checkRequest() error {
if rl.maxRequestsPerMinute <= 0 {
return nil
}
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-time.Minute)
// Remove expired entries
rl.requestTimes = pruneOld(rl.requestTimes, cutoff)
if len(rl.requestTimes) >= rl.maxRequestsPerMinute {
return fmt.Errorf("request limit exceeded (%d/min)", rl.maxRequestsPerMinute)
}
rl.requestTimes = append(rl.requestTimes, now)
return nil
}
// pruneOld removes timestamps older than cutoff.
func pruneOld(times []time.Time, cutoff time.Time) []time.Time {
i := 0
for i < len(times) && times[i].Before(cutoff) {
i++
}
if i == 0 {
return times
}
return times[i:]
}

View file

@ -0,0 +1,5 @@
{
"last_channel": "test:test-chat",
"last_chat_id": "test-chat-id-123",
"timestamp": "2026-02-17T10:56:02.805709569Z"
}

View file

@ -51,6 +51,7 @@ type Config struct {
Tools ToolsConfig `json:"tools"`
Heartbeat HeartbeatConfig `json:"heartbeat"`
Devices DevicesConfig `json:"devices"`
RateLimits RateLimitsConfig `json:"rate_limits"`
mu sync.RWMutex
}
@ -60,6 +61,7 @@ type AgentsConfig struct {
type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
DataDir string `json:"data_dir" env:"PICOCLAW_AGENTS_DEFAULTS_DATA_DIR"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
@ -166,6 +168,11 @@ type DevicesConfig struct {
MonitorUSB bool `json:"monitor_usb" env:"PICOCLAW_DEVICES_MONITOR_USB"`
}
type RateLimitsConfig struct {
MaxToolCallsPerMinute int `json:"max_tool_calls_per_minute" env:"PICOCLAW_RATE_LIMITS_MAX_TOOL_CALLS_PER_MINUTE"` // 0 = unlimited
MaxRequestsPerMinute int `json:"max_requests_per_minute" env:"PICOCLAW_RATE_LIMITS_MAX_REQUESTS_PER_MINUTE"` // 0 = unlimited
}
type ProvidersConfig struct {
Anthropic ProviderConfig `json:"anthropic"`
OpenAI ProviderConfig `json:"openai"`
@ -235,6 +242,7 @@ func DefaultConfig() *Config {
Agents: AgentsConfig{
Defaults: AgentDefaults{
Workspace: "~/.picoclaw/workspace",
DataDir: "~/.picoclaw/data",
RestrictToWorkspace: true,
Provider: "",
Model: "glm-4.7",
@ -269,8 +277,8 @@ func DefaultConfig() *Config {
},
MaixCam: MaixCamConfig{
Enabled: false,
Host: "0.0.0.0",
Port: 18790,
Host: "127.0.0.1",
Port: 18792,
AllowFrom: FlexibleStringSlice{},
},
QQ: QQConfig{
@ -295,7 +303,7 @@ func DefaultConfig() *Config {
Enabled: false,
ChannelSecret: "",
ChannelAccessToken: "",
WebhookHost: "0.0.0.0",
WebhookHost: "127.0.0.1",
WebhookPort: 18791,
WebhookPath: "/webhook/line",
AllowFrom: FlexibleStringSlice{},
@ -322,7 +330,7 @@ func DefaultConfig() *Config {
ShengSuanYun: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
Host: "127.0.0.1",
Port: 18790,
},
Tools: ToolsConfig{
@ -355,6 +363,10 @@ func DefaultConfig() *Config {
Enabled: false,
MonitorUSB: true,
},
RateLimits: RateLimitsConfig{
MaxToolCallsPerMinute: 60,
MaxRequestsPerMinute: 30,
},
}
}
@ -403,6 +415,12 @@ func (c *Config) WorkspacePath() string {
return expandHome(c.Agents.Defaults.Workspace)
}
func (c *Config) DataPath() string {
c.mu.RLock()
defer c.mu.RUnlock()
return expandHome(c.Agents.Defaults.DataDir)
}
func (c *Config) GetAPIKey() string {
c.mu.RLock()
defer c.mu.RUnlock()

View file

@ -67,7 +67,7 @@ func TestDefaultConfig_Temperature(t *testing.T) {
func TestDefaultConfig_Gateway(t *testing.T) {
cfg := DefaultConfig()
if cfg.Gateway.Host != "0.0.0.0" {
if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {
@ -198,6 +198,34 @@ func TestSaveConfig_FilePermissions(t *testing.T) {
}
}
// TestDefaultConfig_DataDir verifies data dir default value
func TestDefaultConfig_DataDir(t *testing.T) {
cfg := DefaultConfig()
if cfg.Agents.Defaults.DataDir == "" {
t.Error("DataDir should not be empty")
}
if cfg.Agents.Defaults.DataDir != "~/.picoclaw/data" {
t.Errorf("DataDir should be '~/.picoclaw/data', got '%s'", cfg.Agents.Defaults.DataDir)
}
}
// TestConfig_DataPath verifies DataPath expands home directory
func TestConfig_DataPath(t *testing.T) {
cfg := DefaultConfig()
path := cfg.DataPath()
if path == "" {
t.Error("DataPath should not be empty")
}
if path == "~/.picoclaw/data" {
t.Error("DataPath should expand ~ to home directory")
}
if path[0] == '~' {
t.Error("DataPath should not start with ~")
}
}
// TestConfig_Complete verifies all config fields are set
func TestConfig_Complete(t *testing.T) {
cfg := DefaultConfig()
@ -218,7 +246,7 @@ func TestConfig_Complete(t *testing.T) {
if cfg.Agents.Defaults.MaxToolIterations == 0 {
t.Error("MaxToolIterations should not be zero")
}
if cfg.Gateway.Host != "0.0.0.0" {
if cfg.Gateway.Host != "127.0.0.1" {
t.Error("Gateway host should have default value")
}
if cfg.Gateway.Port == 0 {

View file

@ -34,6 +34,7 @@ type HeartbeatHandler func(prompt, channel, chatID string) *tools.ToolResult
// HeartbeatService manages periodic heartbeat checks
type HeartbeatService struct {
workspace string
dataDir string
bus *bus.MessageBus
state *state.Manager
handler HeartbeatHandler
@ -44,7 +45,7 @@ type HeartbeatService struct {
}
// NewHeartbeatService creates a new heartbeat service
func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *HeartbeatService {
func NewHeartbeatService(workspace string, dataDir string, intervalMinutes int, enabled bool) *HeartbeatService {
// Apply minimum interval
if intervalMinutes < minIntervalMinutes && intervalMinutes != 0 {
intervalMinutes = minIntervalMinutes
@ -56,9 +57,10 @@ func NewHeartbeatService(workspace string, intervalMinutes int, enabled bool) *H
return &HeartbeatService{
workspace: workspace,
dataDir: dataDir,
interval: time.Duration(intervalMinutes) * time.Minute,
enabled: enabled,
state: state.NewManager(workspace),
state: state.NewManager(dataDir),
}
}
@ -217,7 +219,7 @@ func (hs *HeartbeatService) executeHeartbeat() {
// buildPrompt builds the heartbeat prompt from HEARTBEAT.md
func (hs *HeartbeatService) buildPrompt() string {
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
heartbeatPath := filepath.Join(hs.dataDir, "HEARTBEAT.md")
data, err := os.ReadFile(heartbeatPath)
if err != nil {
@ -249,7 +251,7 @@ If there is nothing that requires attention, respond ONLY with: HEARTBEAT_OK
// createDefaultHeartbeatTemplate creates the default HEARTBEAT.md file
func (hs *HeartbeatService) createDefaultHeartbeatTemplate() {
heartbeatPath := filepath.Join(hs.workspace, "HEARTBEAT.md")
heartbeatPath := filepath.Join(hs.dataDir, "HEARTBEAT.md")
defaultContent := `# Heartbeat Check List
@ -353,7 +355,7 @@ func (hs *HeartbeatService) logError(format string, args ...any) {
// log writes a message to the heartbeat log file
func (hs *HeartbeatService) log(level, format string, args ...any) {
logFile := filepath.Join(hs.workspace, "heartbeat.log")
logFile := filepath.Join(hs.dataDir, "heartbeat.log")
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return

View file

@ -16,7 +16,7 @@ func TestExecuteHeartbeat_Async(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
hs.stopChan = make(chan struct{}) // Enable for testing
asyncCalled := false
@ -54,7 +54,7 @@ func TestExecuteHeartbeat_Error(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
hs.stopChan = make(chan struct{}) // Enable for testing
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
@ -92,7 +92,7 @@ func TestExecuteHeartbeat_Silent(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
hs.stopChan = make(chan struct{}) // Enable for testing
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
@ -130,7 +130,7 @@ func TestHeartbeatService_StartStop(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 1, true)
hs := NewHeartbeatService(tmpDir, tmpDir,1, true)
err = hs.Start()
if err != nil {
@ -149,7 +149,7 @@ func TestHeartbeatService_Disabled(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 1, false)
hs := NewHeartbeatService(tmpDir, tmpDir,1, false)
if hs.enabled != false {
t.Error("Expected service to be disabled")
@ -166,7 +166,7 @@ func TestExecuteHeartbeat_NilResult(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
hs.stopChan = make(chan struct{}) // Enable for testing
hs.SetHandler(func(prompt, channel, chatID string) *tools.ToolResult {
@ -188,7 +188,7 @@ func TestLogPath(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
// Write a log entry
hs.log("INFO", "Test log entry")
@ -208,7 +208,7 @@ func TestHeartbeatFilePath(t *testing.T) {
}
defer os.RemoveAll(tmpDir)
hs := NewHeartbeatService(tmpDir, 30, true)
hs := NewHeartbeatService(tmpDir, tmpDir,30, true)
// Trigger default template creation
hs.buildPrompt()

View file

@ -24,10 +24,11 @@ import (
type HTTPProvider struct {
apiKey string
apiBase string
providerName string
httpClient *http.Client
}
func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
func NewHTTPProvider(apiKey, apiBase, proxy, providerName string) *HTTPProvider {
client := &http.Client{
Timeout: 120 * time.Second,
}
@ -44,6 +45,7 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
return &HTTPProvider{
apiKey: apiKey,
apiBase: strings.TrimRight(apiBase, "/"),
providerName: providerName,
httpClient: client,
}
}
@ -72,8 +74,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
}
if maxTokens, ok := options["max_tokens"].(int); ok {
lowerModel := strings.ToLower(model)
if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") {
if p.providerName == "openai" || p.providerName == "zhipu" || p.providerName == "glm" {
requestBody["max_completion_tokens"] = maxTokens
} else {
requestBody["max_tokens"] = maxTokens
@ -82,9 +83,9 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
if temperature, ok := options["temperature"].(float64); ok {
lowerModel := strings.ToLower(model)
// Kimi k2 models only support temperature=1
if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") {
requestBody["temperature"] = 1.0
// OpenAI reasoning models (o1/o3/o4, gpt-5.x) and Kimi k2 only support temperature=1
if p.providerName == "openai" || (strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2")) {
// Don't send temperature; let the API use its default
} else {
requestBody["temperature"] = temperature
}
@ -374,6 +375,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiKey = cfg.Providers.OpenAI.APIKey
apiBase = cfg.Providers.OpenAI.APIBase
proxy = cfg.Providers.OpenAI.Proxy
providerName = "openai"
if apiBase == "" {
apiBase = "https://api.openai.com/v1"
}
@ -390,6 +392,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiKey = cfg.Providers.Zhipu.APIKey
apiBase = cfg.Providers.Zhipu.APIBase
proxy = cfg.Providers.Zhipu.Proxy
providerName = "zhipu"
if apiBase == "" {
apiBase = "https://open.bigmodel.cn/api/paas/v4"
}
@ -446,5 +449,5 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
}
return NewHTTPProvider(apiKey, apiBase, proxy), nil
return NewHTTPProvider(apiKey, apiBase, proxy, providerName), nil
}

View file

@ -1,171 +0,0 @@
package skills
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type SkillInstaller struct {
workspace string
}
type AvailableSkill struct {
Name string `json:"name"`
Repository string `json:"repository"`
Description string `json:"description"`
Author string `json:"author"`
Tags []string `json:"tags"`
}
type BuiltinSkill struct {
Name string `json:"name"`
Path string `json:"path"`
Enabled bool `json:"enabled"`
}
func NewSkillInstaller(workspace string) *SkillInstaller {
return &SkillInstaller{
workspace: workspace,
}
}
func (si *SkillInstaller) InstallFromGitHub(ctx context.Context, repo string) error {
skillDir := filepath.Join(si.workspace, "skills", filepath.Base(repo))
if _, err := os.Stat(skillDir); err == nil {
return fmt.Errorf("skill '%s' already exists", filepath.Base(repo))
}
url := fmt.Sprintf("https://raw.githubusercontent.com/%s/main/SKILL.md", repo)
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to fetch skill: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("failed to fetch skill: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
if err := os.MkdirAll(skillDir, 0755); err != nil {
return fmt.Errorf("failed to create skill directory: %w", err)
}
skillPath := filepath.Join(skillDir, "SKILL.md")
if err := os.WriteFile(skillPath, body, 0644); err != nil {
return fmt.Errorf("failed to write skill file: %w", err)
}
return nil
}
func (si *SkillInstaller) Uninstall(skillName string) error {
skillDir := filepath.Join(si.workspace, "skills", skillName)
if _, err := os.Stat(skillDir); os.IsNotExist(err) {
return fmt.Errorf("skill '%s' not found", skillName)
}
if err := os.RemoveAll(skillDir); err != nil {
return fmt.Errorf("failed to remove skill: %w", err)
}
return nil
}
func (si *SkillInstaller) ListAvailableSkills(ctx context.Context) ([]AvailableSkill, error) {
url := "https://raw.githubusercontent.com/sipeed/picoclaw-skills/main/skills.json"
client := &http.Client{Timeout: 15 * time.Second}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch skills list: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("failed to fetch skills list: HTTP %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
var skills []AvailableSkill
if err := json.Unmarshal(body, &skills); err != nil {
return nil, fmt.Errorf("failed to parse skills list: %w", err)
}
return skills, nil
}
func (si *SkillInstaller) ListBuiltinSkills() []BuiltinSkill {
builtinSkillsDir := filepath.Join(filepath.Dir(si.workspace), "picoclaw", "skills")
entries, err := os.ReadDir(builtinSkillsDir)
if err != nil {
return nil
}
var skills []BuiltinSkill
for _, entry := range entries {
if entry.IsDir() {
_ = entry
skillName := entry.Name()
skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md")
data, err := os.ReadFile(skillFile)
description := ""
if err == nil {
content := string(data)
if idx := strings.Index(content, "\n"); idx > 0 {
firstLine := content[:idx]
if strings.Contains(firstLine, "description:") {
descLine := strings.Index(content[idx:], "\n")
if descLine > 0 {
description = strings.TrimSpace(content[idx+descLine : idx+descLine])
}
}
}
}
// skill := BuiltinSkill{
// Name: skillName,
// Path: description,
// Enabled: true,
// }
status := "✓"
fmt.Printf(" %s %s\n", status, entry.Name())
if description != "" {
fmt.Printf(" %s\n", description)
}
}
}
return skills
}

View file

@ -52,16 +52,16 @@ func (info SkillInfo) validate() error {
}
type SkillsLoader struct {
workspace string
workspaceSkills string // workspace skills (项目级别)
globalSkills string // 全局 skills (~/.picoclaw/skills)
builtinSkills string // 内置 skills
dataDir string
dataSkills string // data dir skills
globalSkills string // global skills (~/.picoclaw/skills)
builtinSkills string // builtin skills
}
func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string) *SkillsLoader {
func NewSkillsLoader(dataDir string, globalSkills string, builtinSkills string) *SkillsLoader {
return &SkillsLoader{
workspace: workspace,
workspaceSkills: filepath.Join(workspace, "skills"),
dataDir: dataDir,
dataSkills: filepath.Join(dataDir, "skills"),
globalSkills: globalSkills, // ~/.picoclaw/skills
builtinSkills: builtinSkills,
}
@ -70,11 +70,11 @@ func NewSkillsLoader(workspace string, globalSkills string, builtinSkills string
func (sl *SkillsLoader) ListSkills() []SkillInfo {
skills := make([]SkillInfo, 0)
if sl.workspaceSkills != "" {
if dirs, err := os.ReadDir(sl.workspaceSkills); err == nil {
if sl.dataSkills != "" {
if dirs, err := os.ReadDir(sl.dataSkills); err == nil {
for _, dir := range dirs {
if dir.IsDir() {
skillFile := filepath.Join(sl.workspaceSkills, dir.Name(), "SKILL.md")
skillFile := filepath.Join(sl.dataSkills, dir.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); err == nil {
info := SkillInfo{
Name: dir.Name(),
@ -87,7 +87,7 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
info.Name = metadata.Name
}
if err := info.validate(); err != nil {
slog.Warn("invalid skill from workspace", "name", info.Name, "error", err)
slog.Warn("invalid skill from data dir", "name", info.Name, "error", err)
continue
}
skills = append(skills, info)
@ -181,8 +181,8 @@ func (sl *SkillsLoader) ListSkills() []SkillInfo {
func (sl *SkillsLoader) LoadSkill(name string) (string, bool) {
// 1. 优先从 workspace skills 加载(项目级别)
if sl.workspaceSkills != "" {
skillFile := filepath.Join(sl.workspaceSkills, name, "SKILL.md")
if sl.dataSkills != "" {
skillFile := filepath.Join(sl.dataSkills, name, "SKILL.md")
if content, err := os.ReadFile(skillFile); err == nil {
return sl.stripFrontmatter(string(content)), true
}
@ -234,12 +234,10 @@ func (sl *SkillsLoader) BuildSkillsSummary() string {
for _, s := range allSkills {
escapedName := escapeXML(s.Name)
escapedDesc := escapeXML(s.Description)
escapedPath := escapeXML(s.Path)
lines = append(lines, fmt.Sprintf(" <skill>"))
lines = append(lines, fmt.Sprintf(" <name>%s</name>", escapedName))
lines = append(lines, fmt.Sprintf(" <description>%s</description>", escapedDesc))
lines = append(lines, fmt.Sprintf(" <location>%s</location>", escapedPath))
lines = append(lines, fmt.Sprintf(" <source>%s</source>", s.Source))
lines = append(lines, " </skill>")
}

95
pkg/tools/memory.go Normal file
View file

@ -0,0 +1,95 @@
package tools
import (
"context"
"fmt"
)
// MemoryWriter is the interface for memory operations.
// Implemented by agent.MemoryStore.
type MemoryWriter interface {
WriteLongTerm(content string) error
AppendToday(content string) error
ReadLongTerm() string
ReadToday() string
}
type MemoryTool struct {
writer MemoryWriter
}
func NewMemoryTool(writer MemoryWriter) *MemoryTool {
return &MemoryTool{writer: writer}
}
func (t *MemoryTool) Name() string {
return "memory"
}
func (t *MemoryTool) Description() string {
return "Store and retrieve persistent memory. Actions: write_long_term, append_daily, read_long_term, read_daily"
}
func (t *MemoryTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"description": "The memory action to perform",
"enum": []string{"write_long_term", "append_daily", "read_long_term", "read_daily"},
},
"content": map[string]interface{}{
"type": "string",
"description": "Content to write (required for write_long_term and append_daily)",
},
},
"required": []string{"action"},
}
}
func (t *MemoryTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
action, ok := args["action"].(string)
if !ok {
return ErrorResult("action is required")
}
switch action {
case "write_long_term":
content, ok := args["content"].(string)
if !ok || content == "" {
return ErrorResult("content is required for write_long_term")
}
if err := t.writer.WriteLongTerm(content); err != nil {
return ErrorResult(fmt.Sprintf("failed to write long-term memory: %v", err))
}
return SilentResult("Long-term memory updated successfully")
case "append_daily":
content, ok := args["content"].(string)
if !ok || content == "" {
return ErrorResult("content is required for append_daily")
}
if err := t.writer.AppendToday(content); err != nil {
return ErrorResult(fmt.Sprintf("failed to append daily note: %v", err))
}
return SilentResult("Daily note appended successfully")
case "read_long_term":
content := t.writer.ReadLongTerm()
if content == "" {
return SilentResult("No long-term memory found")
}
return SilentResult(content)
case "read_daily":
content := t.writer.ReadToday()
if content == "" {
return SilentResult("No daily notes for today")
}
return SilentResult(content)
default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
}
}

162
pkg/tools/memory_test.go Normal file
View file

@ -0,0 +1,162 @@
package tools
import (
"context"
"testing"
)
type mockMemoryWriter struct {
longTerm string
daily string
}
func (m *mockMemoryWriter) WriteLongTerm(content string) error {
m.longTerm = content
return nil
}
func (m *mockMemoryWriter) AppendToday(content string) error {
m.daily += content
return nil
}
func (m *mockMemoryWriter) ReadLongTerm() string {
return m.longTerm
}
func (m *mockMemoryWriter) ReadToday() string {
return m.daily
}
func TestMemoryTool_WriteLongTerm(t *testing.T) {
w := &mockMemoryWriter{}
tool := NewMemoryTool(w)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "write_long_term",
"content": "User prefers dark mode",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if w.longTerm != "User prefers dark mode" {
t.Errorf("expected 'User prefers dark mode', got '%s'", w.longTerm)
}
}
func TestMemoryTool_WriteLongTerm_MissingContent(t *testing.T) {
w := &mockMemoryWriter{}
tool := NewMemoryTool(w)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "write_long_term",
})
if !result.IsError {
t.Error("expected error for missing content")
}
}
func TestMemoryTool_AppendDaily(t *testing.T) {
w := &mockMemoryWriter{}
tool := NewMemoryTool(w)
tool.Execute(context.Background(), map[string]interface{}{
"action": "append_daily",
"content": "Met with team.",
})
tool.Execute(context.Background(), map[string]interface{}{
"action": "append_daily",
"content": " Discussed roadmap.",
})
if w.daily != "Met with team. Discussed roadmap." {
t.Errorf("expected appended content, got '%s'", w.daily)
}
}
func TestMemoryTool_ReadLongTerm(t *testing.T) {
w := &mockMemoryWriter{longTerm: "stored facts"}
tool := NewMemoryTool(w)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "read_long_term",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if result.ForLLM != "stored facts" {
t.Errorf("expected 'stored facts', got '%s'", result.ForLLM)
}
}
func TestMemoryTool_ReadLongTerm_Empty(t *testing.T) {
w := &mockMemoryWriter{}
tool := NewMemoryTool(w)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "read_long_term",
})
if result.IsError {
t.Error("should not error on empty memory")
}
if result.ForLLM != "No long-term memory found" {
t.Errorf("expected empty message, got '%s'", result.ForLLM)
}
}
func TestMemoryTool_ReadDaily(t *testing.T) {
w := &mockMemoryWriter{daily: "today's notes"}
tool := NewMemoryTool(w)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "read_daily",
})
if result.ForLLM != "today's notes" {
t.Errorf("expected 'today's notes', got '%s'", result.ForLLM)
}
}
func TestMemoryTool_UnknownAction(t *testing.T) {
w := &mockMemoryWriter{}
tool := NewMemoryTool(w)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "delete_all",
})
if !result.IsError {
t.Error("expected error for unknown action")
}
}
func TestMemoryTool_MissingAction(t *testing.T) {
w := &mockMemoryWriter{}
tool := NewMemoryTool(w)
result := tool.Execute(context.Background(), map[string]interface{}{})
if !result.IsError {
t.Error("expected error for missing action")
}
}
func TestMemoryTool_NameAndDescription(t *testing.T) {
w := &mockMemoryWriter{}
tool := NewMemoryTool(w)
if tool.Name() != "memory" {
t.Errorf("expected name 'memory', got '%s'", tool.Name())
}
if tool.Description() == "" {
t.Error("description should not be empty")
}
params := tool.Parameters()
if params == nil {
t.Error("parameters should not be nil")
}
}

77
pkg/tools/skills.go Normal file
View file

@ -0,0 +1,77 @@
package tools
import (
"context"
"fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/skills"
)
type SkillTool struct {
loader *skills.SkillsLoader
}
func NewSkillTool(loader *skills.SkillsLoader) *SkillTool {
return &SkillTool{loader: loader}
}
func (t *SkillTool) Name() string {
return "skill"
}
func (t *SkillTool) Description() string {
return "Read skill instructions. Actions: skill_list (list available skills), skill_read (read a skill's content)"
}
func (t *SkillTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string",
"description": "The skill action to perform",
"enum": []string{"skill_list", "skill_read"},
},
"name": map[string]interface{}{
"type": "string",
"description": "Skill name (required for skill_read)",
},
},
"required": []string{"action"},
}
}
func (t *SkillTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
action, ok := args["action"].(string)
if !ok {
return ErrorResult("action is required")
}
switch action {
case "skill_list":
allSkills := t.loader.ListSkills()
if len(allSkills) == 0 {
return SilentResult("No skills available")
}
var sb strings.Builder
for _, s := range allSkills {
sb.WriteString(fmt.Sprintf("- %s (%s): %s\n", s.Name, s.Source, s.Description))
}
return SilentResult(sb.String())
case "skill_read":
name, ok := args["name"].(string)
if !ok || name == "" {
return ErrorResult("name is required for skill_read")
}
content, found := t.loader.LoadSkill(name)
if !found {
return ErrorResult(fmt.Sprintf("skill %q not found", name))
}
return SilentResult(content)
default:
return ErrorResult(fmt.Sprintf("unknown action: %s", action))
}
}

163
pkg/tools/skills_test.go Normal file
View file

@ -0,0 +1,163 @@
package tools
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/sipeed/picoclaw/pkg/skills"
)
func setupSkillFixture(t *testing.T) (string, *skills.SkillsLoader) {
t.Helper()
tmpDir, err := os.MkdirTemp("", "skill-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
// Create a test skill
skillDir := filepath.Join(tmpDir, "skills", "test-skill")
os.MkdirAll(skillDir, 0755)
os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(`---
name: test-skill
description: "A test skill for unit testing"
---
# Test Skill
This is the skill content.
`), 0644)
loader := skills.NewSkillsLoader(tmpDir, "", "")
return tmpDir, loader
}
func TestSkillTool_SkillList(t *testing.T) {
tmpDir, loader := setupSkillFixture(t)
defer os.RemoveAll(tmpDir)
tool := NewSkillTool(loader)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "skill_list",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if result.ForLLM == "No skills available" {
t.Error("expected skills to be listed")
}
}
func TestSkillTool_SkillList_Empty(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "skill-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
loader := skills.NewSkillsLoader(tmpDir, "", "")
tool := NewSkillTool(loader)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "skill_list",
})
if result.IsError {
t.Error("empty list should not be an error")
}
if result.ForLLM != "No skills available" {
t.Errorf("expected 'No skills available', got '%s'", result.ForLLM)
}
}
func TestSkillTool_SkillRead(t *testing.T) {
tmpDir, loader := setupSkillFixture(t)
defer os.RemoveAll(tmpDir)
tool := NewSkillTool(loader)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "skill_read",
"name": "test-skill",
})
if result.IsError {
t.Fatalf("unexpected error: %s", result.ForLLM)
}
if result.ForLLM == "" {
t.Error("expected skill content")
}
}
func TestSkillTool_SkillRead_NotFound(t *testing.T) {
tmpDir, loader := setupSkillFixture(t)
defer os.RemoveAll(tmpDir)
tool := NewSkillTool(loader)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "skill_read",
"name": "nonexistent",
})
if !result.IsError {
t.Error("expected error for nonexistent skill")
}
}
func TestSkillTool_SkillRead_MissingName(t *testing.T) {
tmpDir, loader := setupSkillFixture(t)
defer os.RemoveAll(tmpDir)
tool := NewSkillTool(loader)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "skill_read",
})
if !result.IsError {
t.Error("expected error for missing name")
}
}
func TestSkillTool_UnknownAction(t *testing.T) {
tmpDir, loader := setupSkillFixture(t)
defer os.RemoveAll(tmpDir)
tool := NewSkillTool(loader)
result := tool.Execute(context.Background(), map[string]interface{}{
"action": "skill_delete",
})
if !result.IsError {
t.Error("expected error for unknown action")
}
}
func TestSkillTool_MissingAction(t *testing.T) {
tmpDir, loader := setupSkillFixture(t)
defer os.RemoveAll(tmpDir)
tool := NewSkillTool(loader)
result := tool.Execute(context.Background(), map[string]interface{}{})
if !result.IsError {
t.Error("expected error for missing action")
}
}
func TestSkillTool_NameAndDescription(t *testing.T) {
tmpDir, loader := setupSkillFixture(t)
defer os.RemoveAll(tmpDir)
tool := NewSkillTool(loader)
if tool.Name() != "skill" {
t.Errorf("expected name 'skill', got '%s'", tool.Name())
}
if tool.Description() == "" {
t.Error("description should not be empty")
}
if tool.Parameters() == nil {
t.Error("parameters should not be nil")
}
}