chore: update Makefile, .gitignore, ROADMAP, and wire XDG in main.go

Makefile: add eval targets, update build/test targets for new layout
.gitignore: add eval fixture outputs and config overrides
ROADMAP.md: add IdentitySync, XDG migration, and eval suite milestones
cmd/picoclaw/main.go:
- Use config.SkillsDir() / config.ConfigDir() for skills subcommands
- Call migrate.MigrateToXDG() during onboard
- createWorkspaceTemplates takes *Config (uses XDG-aware seed functions)
- seedEmbeddedIdentity / seedEmbeddedSkills replace copyEmbeddedToTarget
This commit is contained in:
ZanzyTHEbar 2026-02-19 18:07:00 +00:00
parent 5d5ae0dba3
commit 3130540ab4
4 changed files with 192 additions and 153 deletions

1
.gitignore vendored
View file

@ -43,4 +43,3 @@ tasks/
# Added by goreleaser init:
dist/
eval-runner

View file

@ -222,13 +222,31 @@ eval-build: generate
@$(GO) build $(GOFLAGS) $(LDFLAGS) -o eval/bin/eval-runner ./eval/cmd/eval-runner
@echo "Eval runner built: eval/bin/eval-runner"
## eval: Run the eval suite against the current build
eval: eval-build
@echo "Running eval suite..."
@cd eval && npx promptfoo eval --no-progress-bar
## eval: Run the eval suite (default config only) against the current build
eval: eval-build eval-fixtures
@echo "Running eval suite (default config)..."
@cd eval && npx promptfoo eval --config promptfooconfig-default.yaml --no-progress-bar
@echo "Results: eval/results/latest.json"
@echo "View: cd eval && npx promptfoo view"
## eval-matrix: Run eval suite against all config variants (default, progressive, no-memory)
eval-matrix: eval-build eval-fixtures
@echo "Running eval matrix (all config variants)..."
@cd eval && npx promptfoo eval --config promptfooconfig.yaml --no-progress-bar
@echo "Results: eval/results/latest.json"
@echo "View: cd eval && npx promptfoo view"
## eval-fixtures: Reset workspace to a known state and seed fixture files for eval
eval-fixtures:
@mkdir -p $(HOME)/.picoclaw/workspace
@rm -f $(HOME)/.picoclaw/workspace/eval_test_output.txt \
$(HOME)/.picoclaw/workspace/test_steps.txt \
$(HOME)/.picoclaw/workspace/eval_checkpoint.txt
@printf 'picoclaw eval fixture — hello from the eval harness\nThis is line two of the fixture file.\n' > $(HOME)/.picoclaw/workspace/eval_fixture.txt
@cp -f eval/fixtures/sample_data.txt $(HOME)/.picoclaw/workspace/sample_data.txt
@mkdir -p $(HOME)/.picoclaw/workspace/skills
@cp -rf eval/fixtures/skills/* $(HOME)/.picoclaw/workspace/skills/ 2>/dev/null || true
## eval-view: Open the promptfoo results viewer
eval-view:
@cd eval && npx promptfoo view

View file

@ -229,6 +229,33 @@ flowchart LR
## Ideas and improvements
- [ ] Add a new tool for the agent to use: `focus_search`
- [ ] Clean up the main.go and extract to modules
- [ ] Create a pure Application API that I/O calls into
- [ ] cli
- [ ] daemon
- [ ] web
- [ ] grpc
- [ ] http
- [ ] websocket
- [ ] tcp
- [ ] udp
- [ ] serial
- [ ] i2c
- [ ] spi
- [ ] pwm
- [ ] etc
- [ ] all of these should be able to be configured and plugged in/out at runtime
- [ ] Migrate to Cobra CLI framework
- [ ] Use command-palette pattern for subcommands
- [ ] keep cli commands as pure cli that calls into the application
- [ ] Consolidate tool signatures:
- [ ] fold tools that operate on the same data into a single tool with a "mode"/"action"/"event" parameter
- [ ] move the "mode"/"action"/"event" parameter to the beginning of the tool signature
- [ ] add a "description" field to the tool signature
- [ ] this reduces the number of tools and makes the tool signatures more consistent
- [ ] Tools can then live in packages like `pkg/tools/filesystem.go` and `pkg/tools/network.go` where we expose only a limited ABI/API to the agent loop
- [ ] Extract out all inline prompts into separate files
- [ ] Use dotprompt, poml, or any other structured prompt builder to build prompts
- [ ] Isolated tool runtime + DAG executor + RLM engine — see [ADR-001](docs/adr/001-isolated-tool-runtime.md)
- [ ] Layer 1: Capability manifests (`CapableTool` interface)
- [ ] Layer 2: SecureBus + FlatBuffers command protocol (incl. DAG types) + leak scanning

View file

@ -155,19 +155,12 @@ func main() {
subcommand := os.Args[2]
cfg, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
os.Exit(1)
}
workspace := cfg.WorkspacePath()
installer := skills.NewSkillInstaller(workspace)
// 获取全局配置目录和内置 skills 目录
globalDir := filepath.Dir(getConfigPath())
globalSkillsDir := filepath.Join(globalDir, "skills")
builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills")
skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir)
skillsDir, _ := config.SkillsDir()
installer := skills.NewSkillInstaller(skillsDir)
cfgDir, _ := config.ConfigDir()
globalSkillsDir := filepath.Join(cfgDir, "skills")
builtinSkillsDir := filepath.Join(cfgDir, "picoclaw", "skills")
skillsLoader := skills.NewSkillsLoader(skillsDir, globalSkillsDir, builtinSkillsDir)
switch subcommand {
case "list":
@ -181,7 +174,7 @@ func main() {
}
skillsRemoveCmd(installer, os.Args[3])
case "install-builtin":
skillsInstallBuiltinCmd(workspace)
skillsInstallBuiltinCmd(skillsDir)
case "list-builtin":
skillsListBuiltinCmd()
case "search":
@ -250,8 +243,11 @@ func onboard() {
os.Exit(1)
}
workspace := cfg.WorkspacePath()
createWorkspaceTemplates(workspace)
if migErr := migrate.MigrateToXDG(""); migErr != nil {
fmt.Printf("Warning: XDG migration failed: %v\n", migErr)
}
createWorkspaceTemplates(cfg)
fmt.Printf("%s picoclaw is ready!\n", logo)
@ -280,58 +276,68 @@ func onboard() {
fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"")
}
func copyEmbeddedToTarget(targetDir string) error {
// Ensure target directory exists
if err := os.MkdirAll(targetDir, 0755); err != nil {
return fmt.Errorf("Failed to create target directory: %w", err)
}
// Walk through all files in embed.FS
err := fs.WalkDir(embeddedFiles, "workspace", func(path string, d fs.DirEntry, err error) error {
// seedEmbeddedIdentity copies identity template files from the embedded FS
// into the XDG identity directory ($XDG_CONFIG_HOME/picoclaw/identity/).
func seedEmbeddedIdentity(identityDir string) error {
identityFiles := []string{"AGENT.md", "IDENTITY.md", "SOUL.md", "USER.md"}
for _, name := range identityFiles {
data, err := embeddedFiles.ReadFile("workspace/" + name)
if err != nil {
return err
continue
}
// Skip directories
if d.IsDir() {
return nil
targetPath := filepath.Join(identityDir, name)
if _, err := os.Stat(targetPath); err == nil {
continue
}
// Read embedded file
data, err := embeddedFiles.ReadFile(path)
if err != nil {
return fmt.Errorf("Failed to read embedded file %s: %w", path, err)
}
new_path, err := filepath.Rel("workspace", path)
if err != nil {
return fmt.Errorf("Failed to get relative path for %s: %v\n", path, err)
}
// Build target file path
targetPath := filepath.Join(targetDir, new_path)
// Ensure target file's directory exists
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return fmt.Errorf("Failed to create directory %s: %w", filepath.Dir(targetPath), err)
}
// Write file
if err := os.WriteFile(targetPath, data, 0644); err != nil {
return fmt.Errorf("Failed to write file %s: %w", targetPath, err)
return fmt.Errorf("write %s: %w", targetPath, err)
}
}
return nil
}
return nil
})
// seedEmbeddedSkills copies skill templates from the embedded FS into the
// XDG skills directory ($XDG_DATA_HOME/picoclaw/skills/).
func seedEmbeddedSkills(skillsDir string) error {
return fs.WalkDir(embeddedFiles, "workspace/skills", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
func createWorkspaceTemplates(workspace string) {
err := copyEmbeddedToTarget(workspace)
if err != nil {
fmt.Printf("Error copying workspace templates: %v\n", err)
data, readErr := embeddedFiles.ReadFile(path)
if readErr != nil {
return readErr
}
relPath, _ := filepath.Rel("workspace/skills", path)
targetPath := filepath.Join(skillsDir, relPath)
if _, statErr := os.Stat(targetPath); statErr == nil {
return nil
}
os.MkdirAll(filepath.Dir(targetPath), 0755)
return os.WriteFile(targetPath, data, 0644)
})
}
func createWorkspaceTemplates(cfg *config.Config) {
identityDir, err := config.IdentityDir()
if err != nil {
fmt.Printf("Error resolving identity dir: %v\n", err)
return
}
if err := seedEmbeddedIdentity(identityDir); err != nil {
fmt.Printf("Error seeding identity files: %v\n", err)
}
skillsDir, err := config.SkillsDir()
if err != nil {
fmt.Printf("Error resolving skills dir: %v\n", err)
return
}
if err := seedEmbeddedSkills(skillsDir); err != nil {
fmt.Printf("Error seeding skills: %v\n", err)
}
// Ensure sandbox directory exists.
_ = os.MkdirAll(cfg.SandboxPath(), 0755)
}
func migrateCmd() {
@ -439,21 +445,25 @@ func agentCmd() {
os.Exit(1)
}
ctx := context.Background()
languageModel, err := fantasyProvider.LanguageModel(ctx, picofantasy.ModelID(cfg))
appCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
languageModel, err := fantasyProvider.LanguageModel(appCtx, picofantasy.ModelID(cfg))
if err != nil {
fmt.Printf("Error creating language model: %v\n", err)
os.Exit(1)
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, languageModel)
agentLoop, err := agent.NewAgentLoop(appCtx, cfg, msgBus, languageModel)
if err != nil {
fmt.Printf("Error initializing agent: %v\n", err)
os.Exit(1)
}
// Wire ITR SecureBus — routes all tool calls through capability enforcement.
closeBus := setupSecureBus(agentLoop)
defer closeBus()
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
logger.InfoCF("agent", "Agent initialized",
map[string]interface{}{
@ -463,7 +473,7 @@ func agentCmd() {
})
if message != "" {
response, err := agentLoop.ProcessDirect(ctx, message, sessionKey)
response, err := agentLoop.ProcessDirect(appCtx, message, sessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
@ -471,11 +481,11 @@ func agentCmd() {
fmt.Printf("\n%s %s\n", logo, response)
} else {
fmt.Printf("%s Interactive mode (Ctrl+C to exit)\n\n", logo)
interactiveMode(agentLoop, sessionKey)
interactiveMode(appCtx, agentLoop, sessionKey)
}
}
func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
func interactiveMode(ctx context.Context, agentLoop *agent.AgentLoop, sessionKey string) {
prompt := fmt.Sprintf("%s You: ", logo)
rl, err := readline.NewEx(&readline.Config{
@ -489,7 +499,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
if err != nil {
fmt.Printf("Error initializing readline: %v\n", err)
fmt.Println("Falling back to simple input mode...")
simpleInteractiveMode(agentLoop, sessionKey)
simpleInteractiveMode(ctx, agentLoop, sessionKey)
return
}
defer rl.Close()
@ -515,7 +525,6 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
return
}
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
@ -526,10 +535,10 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
}
}
func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
func simpleInteractiveMode(ctx context.Context, agentLoop *agent.AgentLoop, sessionKey string) {
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print(fmt.Sprintf("%s You: ", logo))
fmt.Printf("%s You: ", logo)
line, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
@ -550,7 +559,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
return
}
ctx := context.Background()
response, err := agentLoop.ProcessDirect(ctx, input, sessionKey)
if err != nil {
fmt.Printf("Error: %v\n", err)
@ -584,20 +592,25 @@ func gatewayCmd() {
os.Exit(1)
}
languageModel, err := fantasyProvider.LanguageModel(context.Background(), picofantasy.ModelID(cfg))
appCtx, cancel := context.WithCancel(context.Background())
defer cancel()
languageModel, err := fantasyProvider.LanguageModel(appCtx, picofantasy.ModelID(cfg))
if err != nil {
fmt.Printf("Error creating language model: %v\n", err)
os.Exit(1)
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, languageModel)
agentLoop, err := agent.NewAgentLoop(appCtx, cfg, msgBus, languageModel)
if err != nil {
fmt.Printf("Error initializing agent: %v\n", err)
os.Exit(1)
}
// Wire ITR SecureBus — routes all tool calls through capability enforcement.
closeBus := setupSecureBus(agentLoop)
defer closeBus()
// Print agent startup info
fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo()
toolsInfo := startupInfo["tools"].(map[string]interface{})
@ -621,10 +634,10 @@ func gatewayCmd() {
if del := agentLoop.MemoryDelegate(); del != nil {
cronOpts = append(cronOpts, cron.WithCronDelegate(del, "picoclaw"))
}
cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cronOpts...)
cronService := setupCronTool(appCtx, agentLoop, msgBus, cfg.SandboxPath(), cfg.RestrictToSandbox(), execTimeout, cronOpts...)
heartbeatService := heartbeat.NewHeartbeatService(
cfg.WorkspacePath(),
cfg.SandboxPath(),
cfg.Heartbeat.Interval,
cfg.Heartbeat.Enabled,
)
@ -635,7 +648,7 @@ func gatewayCmd() {
channel, chatID = "cli", "direct"
}
// Use ProcessHeartbeat - no session history, each heartbeat is independent
response, err := agentLoop.ProcessHeartbeat(context.Background(), prompt, channel, chatID)
response, err := agentLoop.ProcessHeartbeat(appCtx, prompt, channel, chatID)
if err != nil {
return tools.ErrorResult(fmt.Sprintf("Heartbeat error: %v", err))
}
@ -693,9 +706,6 @@ func gatewayCmd() {
fmt.Printf("✓ Gateway started on %s:%d\n", cfg.Gateway.Host, cfg.Gateway.Port)
fmt.Println("Press Ctrl+C to stop")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := cronService.Start(); err != nil {
fmt.Printf("Error starting cron service: %v\n", err)
}
@ -706,19 +716,19 @@ func gatewayCmd() {
}
fmt.Println("✓ Heartbeat service started")
stateManager := state.NewManager(cfg.WorkspacePath())
stateManager := state.NewManager(cfg.SandboxPath())
deviceService := devices.NewService(devices.Config{
Enabled: cfg.Devices.Enabled,
MonitorUSB: cfg.Devices.MonitorUSB,
}, stateManager)
deviceService.SetBus(msgBus)
if err := deviceService.Start(ctx); err != nil {
if err := deviceService.Start(appCtx); err != nil {
fmt.Printf("Error starting device service: %v\n", err)
} else if cfg.Devices.Enabled {
fmt.Println("✓ Device event service started")
}
if err := channelManager.StartAll(ctx); err != nil {
if err := channelManager.StartAll(appCtx); err != nil {
fmt.Printf("Error starting channels: %v\n", err)
}
@ -730,7 +740,7 @@ func gatewayCmd() {
}()
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port)
go agentLoop.Run(ctx)
go agentLoop.Run(appCtx)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt)
@ -738,12 +748,15 @@ func gatewayCmd() {
fmt.Println("\nShutting down...")
cancel()
healthServer.Stop(context.Background())
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer shutdownCancel()
healthServer.Stop(shutdownCtx)
deviceService.Stop()
heartbeatService.Stop()
cronService.Stop()
agentLoop.Stop()
channelManager.StopAll(ctx)
channelManager.StopAll(shutdownCtx)
fmt.Println("✓ Gateway stopped")
}
@ -784,16 +797,7 @@ func memoryMigrateSessions() {
os.Exit(1)
}
if !cfg.Memory.Enabled {
fmt.Println("Memory system is disabled in config. Enable it first.")
os.Exit(1)
}
workspace := cfg.WorkspacePath()
memDBPath := cfg.Memory.DBPath
if memDBPath == "" {
memDBPath = filepath.Join(workspace, "memory", "picoclaw.db")
}
memDBPath := cfg.DBPath()
os.MkdirAll(filepath.Dir(memDBPath), 0755)
del, err := delegate.NewFromConfig(cfg.Memory, memDBPath)
@ -809,7 +813,7 @@ func memoryMigrateSessions() {
os.Exit(1)
}
sessionsDir := filepath.Join(workspace, "sessions")
sessionsDir := filepath.Join(cfg.SandboxPath(), "sessions")
stats, err := picomemory.MigrateFileSessions(ctx, del, "picoclaw", sessionsDir)
if err != nil {
fmt.Printf("Migration error: %v\n", err)
@ -830,11 +834,7 @@ func memoryDBStatus() {
os.Exit(1)
}
workspace := cfg.WorkspacePath()
memDBPath := cfg.Memory.DBPath
if memDBPath == "" {
memDBPath = filepath.Join(workspace, "memory", "picoclaw.db")
}
memDBPath := cfg.DBPath()
fi, err := os.Stat(memDBPath)
if err != nil {
@ -876,11 +876,11 @@ func statusCmd() {
fmt.Println("Config:", configPath, "✗")
}
workspace := cfg.WorkspacePath()
if _, err := os.Stat(workspace); err == nil {
fmt.Println("Workspace:", workspace, "✓")
sandboxPath := cfg.SandboxPath()
if _, err := os.Stat(sandboxPath); err == nil {
fmt.Println("Sandbox:", sandboxPath, "✓")
} else {
fmt.Println("Workspace:", workspace, "✗")
fmt.Println("Sandbox:", sandboxPath, "✗")
}
if _, err := os.Stat(configPath); err == nil {
@ -926,10 +926,8 @@ func statusCmd() {
}
}
// Memory system status
// Memory system status (always enabled)
fmt.Println("\nMemory System:")
if cfg.Memory.Enabled {
fmt.Println(" Enabled: ✓")
fmt.Printf(" Embedding dims: %d\n", cfg.Memory.EmbeddingDims)
if cfg.Memory.Embedding.Provider != "" {
fmt.Printf(" Embedding provider: %s\n", cfg.Memory.Embedding.Provider)
@ -947,14 +945,11 @@ func statusCmd() {
}
memDBPath := cfg.Memory.DBPath
if memDBPath == "" {
memDBPath = filepath.Join(workspace, "memory", "picoclaw.db")
memDBPath = filepath.Join(cfg.WorkspacePath(), "memory", "picoclaw.db")
}
if fi, err := os.Stat(memDBPath); err == nil {
fmt.Printf(" DB size: %.1f KB\n", float64(fi.Size())/1024)
}
} else {
fmt.Println(" Enabled: ✗")
}
}
}
@ -1419,13 +1414,13 @@ func daemonStart() {
}
registry := tools.NewToolRegistry()
workspace := cfg.WorkspacePath()
restrict := cfg.Agents.Defaults.RestrictToWorkspace
registry.Register(tools.NewExecTool(workspace, restrict))
registry.Register(tools.NewReadFileTool(workspace, restrict))
registry.Register(tools.NewWriteFileTool(workspace, restrict))
registry.Register(tools.NewListDirTool(workspace, restrict))
registry.Register(tools.NewEditFileTool(workspace, restrict))
sandbox := cfg.SandboxPath()
restrict := cfg.RestrictToSandbox()
registry.Register(tools.NewExecTool(sandbox, restrict))
registry.Register(tools.NewReadFileTool(sandbox, restrict))
registry.Register(tools.NewWriteFileTool(sandbox, restrict))
registry.Register(tools.NewListDirTool(sandbox, restrict))
registry.Register(tools.NewEditFileTool(sandbox, restrict))
capLookup := func(name string) (tools.ToolCapabilities, bool) {
t, ok := registry.Get(name)
@ -1443,7 +1438,7 @@ func daemonStart() {
defer secureBus.Close()
fmt.Printf("picoclaw daemon started (pid=%d, socket=%s)\n", pid, sockPath)
fmt.Printf(" workspace: %s\n", workspace)
fmt.Printf(" sandbox: %s\n", sandbox)
fmt.Printf(" tools: %d registered\n", len(registry.List()))
fmt.Println("Press Ctrl+C to stop.")
@ -1541,18 +1536,18 @@ func getConfigPath() string {
return filepath.Join(home, ".picoclaw", "config.json")
}
func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cronOpts ...cron.CronOption) *cron.CronService {
func setupCronTool(appCtx context.Context, agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, cronOpts ...cron.CronOption) *cron.CronService {
cronStorePath := filepath.Join(workspace, "cron", "jobs.json")
cronService := cron.NewCronService(cronStorePath, nil, cronOpts...)
// Create and register CronTool
cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout)
agentLoop.RegisterTool(cronTool)
// Set the onJob handler
cronService.SetOnJob(func(job *cron.CronJob) (string, error) {
result := cronTool.ExecuteJob(context.Background(), job)
jobCtx, jobCancel := context.WithTimeout(appCtx, execTimeout)
defer jobCancel()
result := cronTool.ExecuteJob(jobCtx, job)
return result, nil
})
@ -1620,7 +1615,7 @@ func cronCmd() {
return
}
cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json")
cronStorePath := filepath.Join(cfg.SandboxPath(), "cron", "jobs.json")
switch subcommand {
case "list":
@ -1924,12 +1919,12 @@ func skillsInstallBuiltinCmd(workspace string) {
}
func skillsListBuiltinCmd() {
cfg, err := loadConfig()
_, err := loadConfig()
if err != nil {
fmt.Printf("Error loading config: %v\n", err)
return
}
builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills")
builtinSkillsDir, _ := config.SkillsDir()
fmt.Println("\nAvailable Builtin Skills:")
fmt.Println("-----------------------")