refactor(config)!: rename Workspace→Sandbox, remove Memory.Enabled, add XDG helpers
- AgentDefaults: Workspace/RestrictToWorkspace → Sandbox/RestrictToSandbox; old fields kept as deprecated omitempty for backward compat during migration - MemoryConfig: remove Enabled field — memory is now always initialized; callers that checked cfg.Memory.Enabled should remove the guard - ToolsConfig: remove ProgressiveDisclosure flag — always-on now - DefaultConfig: Sandbox defaults to empty string (resolved via SandboxDir() at runtime via XDG helpers) - Add SandboxPath(), DBPath(), RestrictToSandbox() convenience methods BREAKING CHANGE: Memory.Enabled removed; memory init is mandatory and errors are returned instead of silently disabling the subsystem
This commit is contained in:
parent
cb31c3af0b
commit
3778d4f025
2 changed files with 157 additions and 70 deletions
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
|
@ -57,10 +58,9 @@ type Config struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MemoryConfig configures the 3-tier MemGPT memory system.
|
// MemoryConfig configures the 3-tier MemGPT memory system.
|
||||||
|
// Memory is always enabled; there is no opt-out. Configuration controls
|
||||||
|
// the database path, embedding dimensions, offloading threshold, and sync.
|
||||||
type MemoryConfig struct {
|
type MemoryConfig struct {
|
||||||
// Enabled controls whether the memory system is initialized. Default: true.
|
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_MEMORY_ENABLED"`
|
|
||||||
|
|
||||||
// DBPath overrides the default database path (workspace/memory/picoclaw.db).
|
// DBPath overrides the default database path (workspace/memory/picoclaw.db).
|
||||||
// Empty string uses the default.
|
// Empty string uses the default.
|
||||||
DBPath string `json:"db_path" env:"PICOCLAW_MEMORY_DB_PATH"`
|
DBPath string `json:"db_path" env:"PICOCLAW_MEMORY_DB_PATH"`
|
||||||
|
|
@ -126,13 +126,19 @@ type AgentsConfig struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentDefaults struct {
|
type AgentDefaults struct {
|
||||||
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
// Sandbox is the directory for agent file operations (tools sandbox).
|
||||||
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
// Defaults to $XDG_DATA_HOME/picoclaw/sandbox when empty.
|
||||||
|
Sandbox string `json:"sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_SANDBOX"`
|
||||||
|
RestrictToSandbox bool `json:"restrict_to_sandbox" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_SANDBOX"`
|
||||||
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
|
||||||
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
|
||||||
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
|
||||||
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
Temperature float64 `json:"temperature" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"`
|
||||||
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"`
|
||||||
|
|
||||||
|
// Deprecated: Use Sandbox instead. Kept for backward compatibility during migration.
|
||||||
|
Workspace string `json:"workspace,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
|
||||||
|
RestrictToWorkspace bool `json:"restrict_to_workspace,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ChannelsConfig struct {
|
type ChannelsConfig struct {
|
||||||
|
|
@ -297,7 +303,6 @@ type CronToolsConfig struct {
|
||||||
|
|
||||||
type ToolsConfig struct {
|
type ToolsConfig struct {
|
||||||
Web WebToolsConfig `json:"web"`
|
Web WebToolsConfig `json:"web"`
|
||||||
ProgressiveDisclosure bool `json:"progressive_disclosure" env:"PICOCLAW_TOOLS_PROGRESSIVE_DISCLOSURE"`
|
|
||||||
Cron CronToolsConfig `json:"cron"`
|
Cron CronToolsConfig `json:"cron"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -305,8 +310,8 @@ func DefaultConfig() *Config {
|
||||||
return &Config{
|
return &Config{
|
||||||
Agents: AgentsConfig{
|
Agents: AgentsConfig{
|
||||||
Defaults: AgentDefaults{
|
Defaults: AgentDefaults{
|
||||||
Workspace: "~/.picoclaw/workspace",
|
Sandbox: "", // empty → resolved via SandboxDir() at runtime
|
||||||
RestrictToWorkspace: true,
|
RestrictToSandbox: true,
|
||||||
Provider: "",
|
Provider: "",
|
||||||
Model: "glm-4.7",
|
Model: "glm-4.7",
|
||||||
MaxTokens: 8192,
|
MaxTokens: 8192,
|
||||||
|
|
@ -416,13 +421,11 @@ func DefaultConfig() *Config {
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
ProgressiveDisclosure: false,
|
|
||||||
Cron: CronToolsConfig{
|
Cron: CronToolsConfig{
|
||||||
ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
|
ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Memory: MemoryConfig{
|
Memory: MemoryConfig{
|
||||||
Enabled: true,
|
|
||||||
EmbeddingDims: 768,
|
EmbeddingDims: 768,
|
||||||
OffloadThresholdTokens: 4000,
|
OffloadThresholdTokens: 4000,
|
||||||
Sync: MemorySyncConfig{
|
Sync: MemorySyncConfig{
|
||||||
|
|
@ -498,8 +501,7 @@ func (c *Config) Validate() []string {
|
||||||
warnings = append(warnings, fmt.Sprintf("heartbeat.interval=%d: must be >= 0", c.Heartbeat.Interval))
|
warnings = append(warnings, fmt.Sprintf("heartbeat.interval=%d: must be >= 0", c.Heartbeat.Interval))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Memory config validation
|
// Memory config validation (memory is always enabled)
|
||||||
if c.Memory.Enabled {
|
|
||||||
if c.Memory.Sync.SyncURL != "" && c.Memory.Sync.AuthToken == "" {
|
if c.Memory.Sync.SyncURL != "" && c.Memory.Sync.AuthToken == "" {
|
||||||
warnings = append(warnings, "memory.sync.sync_url is set but auth_token is empty: Turso replication will likely fail")
|
warnings = append(warnings, "memory.sync.sync_url is set but auth_token is empty: Turso replication will likely fail")
|
||||||
}
|
}
|
||||||
|
|
@ -514,7 +516,6 @@ func (c *Config) Validate() []string {
|
||||||
if embProvider == "openai" && c.Memory.Embedding.APIKey == "" && c.Providers.OpenAI.APIKey == "" {
|
if embProvider == "openai" && c.Memory.Embedding.APIKey == "" && c.Providers.OpenAI.APIKey == "" {
|
||||||
warnings = append(warnings, "memory.embedding.provider=openai but no API key found in memory.embedding.api_key or providers.openai.api_key")
|
warnings = append(warnings, "memory.embedding.provider=openai but no API key found in memory.embedding.api_key or providers.openai.api_key")
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return warnings
|
return warnings
|
||||||
}
|
}
|
||||||
|
|
@ -536,10 +537,51 @@ func SaveConfig(path string, cfg *Config) error {
|
||||||
return os.WriteFile(path, data, 0600)
|
return os.WriteFile(path, data, 0600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SandboxPath returns the resolved sandbox directory for agent file operations.
|
||||||
|
// Priority: explicit Sandbox config > XDG SandboxDir().
|
||||||
|
func (c *Config) SandboxPath() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if c.Agents.Defaults.Sandbox != "" {
|
||||||
|
return expandHome(c.Agents.Defaults.Sandbox)
|
||||||
|
}
|
||||||
|
if dir, err := SandboxDir(); err == nil {
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
return expandHome("~/.local/share/picoclaw/sandbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestrictToSandbox returns whether tool file operations should be restricted
|
||||||
|
// to the sandbox directory. Also checks the deprecated RestrictToWorkspace field.
|
||||||
|
func (c *Config) RestrictToSandbox() bool {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
return c.Agents.Defaults.RestrictToSandbox || c.Agents.Defaults.RestrictToWorkspace
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkspacePath returns the legacy workspace path for backward compatibility.
|
||||||
|
// Deprecated: callers should migrate to SandboxPath().
|
||||||
func (c *Config) WorkspacePath() string {
|
func (c *Config) WorkspacePath() string {
|
||||||
c.mu.RLock()
|
c.mu.RLock()
|
||||||
defer c.mu.RUnlock()
|
defer c.mu.RUnlock()
|
||||||
|
if c.Agents.Defaults.Workspace != "" {
|
||||||
return expandHome(c.Agents.Defaults.Workspace)
|
return expandHome(c.Agents.Defaults.Workspace)
|
||||||
|
}
|
||||||
|
return c.SandboxPath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBPath returns the resolved database path.
|
||||||
|
// Priority: explicit Memory.DBPath config > XDG DefaultDBPath().
|
||||||
|
func (c *Config) DBPath() string {
|
||||||
|
c.mu.RLock()
|
||||||
|
defer c.mu.RUnlock()
|
||||||
|
if c.Memory.DBPath != "" {
|
||||||
|
return expandHome(c.Memory.DBPath)
|
||||||
|
}
|
||||||
|
if p, err := DefaultDBPath(); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
return expandHome("~/.local/share/picoclaw/picoclaw.db")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) GetAPIKey() string {
|
func (c *Config) GetAPIKey() string {
|
||||||
|
|
@ -625,22 +667,83 @@ func ConfigDir() (string, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// DataDir returns the platform-appropriate user data directory for picoclaw.
|
// DataDir returns the platform-appropriate user data directory for picoclaw.
|
||||||
// On Linux this is ~/.local/share/picoclaw (XDG_DATA_HOME); on macOS and
|
// On Linux this respects XDG_DATA_HOME (default ~/.local/share/picoclaw).
|
||||||
// Windows it falls back to the same base as ConfigDir. The directory is
|
// On macOS it uses ~/Library/Application Support/picoclaw; on Windows
|
||||||
// created if it does not exist.
|
// %LOCALAPPDATA%\picoclaw. The directory is created if it does not exist.
|
||||||
func DataDir() (string, error) {
|
func DataDir() (string, error) {
|
||||||
// XDG_DATA_HOME is Linux-standard; os.UserHomeDir gives us the root we need.
|
var base string
|
||||||
|
switch runtime.GOOS {
|
||||||
|
case "linux", "freebsd", "openbsd", "netbsd":
|
||||||
|
if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" {
|
||||||
|
base = xdg
|
||||||
|
} else {
|
||||||
home, err := os.UserHomeDir()
|
home, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("resolve home dir: %w", err)
|
return "", fmt.Errorf("resolve home dir: %w", err)
|
||||||
}
|
}
|
||||||
dir := filepath.Join(home, ".local", "share", appName)
|
base = filepath.Join(home, ".local", "share")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// macOS, Windows: data and config share a base directory.
|
||||||
|
cfgBase, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("resolve user config dir: %w", err)
|
||||||
|
}
|
||||||
|
base = cfgBase
|
||||||
|
}
|
||||||
|
dir := filepath.Join(base, appName)
|
||||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
return "", fmt.Errorf("create data dir %q: %w", dir, err)
|
return "", fmt.Errorf("create data dir %q: %w", dir, err)
|
||||||
}
|
}
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IdentityDir returns the directory containing human-editable identity files
|
||||||
|
// (AGENT.md, IDENTITY.md, SOUL.md, USER.md) inside ConfigDir. These files
|
||||||
|
// are the user-facing surface for agent configuration; a sync layer mirrors
|
||||||
|
// their content into the database at runtime.
|
||||||
|
func IdentityDir() (string, error) {
|
||||||
|
cfgDir, err := ConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(cfgDir, "identity")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("create identity dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SkillsDir returns the directory for installed skills inside DataDir.
|
||||||
|
// Skills are filesystem-native artifacts (SKILL.md + scripts + references)
|
||||||
|
// and remain on disk rather than being synced to the database.
|
||||||
|
func SkillsDir() (string, error) {
|
||||||
|
dataDir, err := DataDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(dataDir, "skills")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("create skills dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SandboxDir returns the directory used as the agent's filesystem operations
|
||||||
|
// workspace inside DataDir. Tool file I/O (read, write, edit, shell) is
|
||||||
|
// restricted to this directory when restrict_to_sandbox is enabled.
|
||||||
|
func SandboxDir() (string, error) {
|
||||||
|
dataDir, err := DataDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(dataDir, "sandbox")
|
||||||
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||||
|
return "", fmt.Errorf("create sandbox dir %q: %w", dir, err)
|
||||||
|
}
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CacheDir returns the platform-appropriate user cache directory for picoclaw
|
// CacheDir returns the platform-appropriate user cache directory for picoclaw
|
||||||
// (XDG_CACHE_HOME on Linux → ~/.cache/picoclaw). The directory is created if
|
// (XDG_CACHE_HOME on Linux → ~/.cache/picoclaw). The directory is created if
|
||||||
// it does not exist.
|
// it does not exist.
|
||||||
|
|
|
||||||
|
|
@ -17,14 +17,12 @@ func TestDefaultConfig_HeartbeatEnabled(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDefaultConfig_WorkspacePath verifies workspace path is correctly set
|
// TestDefaultConfig_SandboxPath verifies sandbox path is resolvable
|
||||||
func TestDefaultConfig_WorkspacePath(t *testing.T) {
|
func TestDefaultConfig_SandboxPath(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
path := cfg.SandboxPath()
|
||||||
// Just verify the workspace is set, don't compare exact paths
|
if path == "" {
|
||||||
// since expandHome behavior may differ based on environment
|
t.Error("SandboxPath should not be empty")
|
||||||
if cfg.Agents.Defaults.Workspace == "" {
|
|
||||||
t.Error("Workspace should not be empty")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -180,8 +178,8 @@ func TestConfig_Complete(t *testing.T) {
|
||||||
cfg := DefaultConfig()
|
cfg := DefaultConfig()
|
||||||
|
|
||||||
// Verify complete config structure
|
// Verify complete config structure
|
||||||
if cfg.Agents.Defaults.Workspace == "" {
|
if cfg.SandboxPath() == "" {
|
||||||
t.Error("Workspace should not be empty")
|
t.Error("SandboxPath should not be empty")
|
||||||
}
|
}
|
||||||
if cfg.Agents.Defaults.Model == "" {
|
if cfg.Agents.Defaults.Model == "" {
|
||||||
t.Error("Model should not be empty")
|
t.Error("Model should not be empty")
|
||||||
|
|
@ -238,7 +236,6 @@ func TestValidate_MemoryConfig(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "sync_url without auth_token",
|
name: "sync_url without auth_token",
|
||||||
mutate: func(c *Config) {
|
mutate: func(c *Config) {
|
||||||
c.Memory.Enabled = true
|
|
||||||
c.Memory.Sync.SyncURL = "libsql://test.turso.io"
|
c.Memory.Sync.SyncURL = "libsql://test.turso.io"
|
||||||
},
|
},
|
||||||
wantWarn: "auth_token is empty",
|
wantWarn: "auth_token is empty",
|
||||||
|
|
@ -246,7 +243,6 @@ func TestValidate_MemoryConfig(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "invalid embedding dims",
|
name: "invalid embedding dims",
|
||||||
mutate: func(c *Config) {
|
mutate: func(c *Config) {
|
||||||
c.Memory.Enabled = true
|
|
||||||
c.Memory.EmbeddingDims = 10
|
c.Memory.EmbeddingDims = 10
|
||||||
},
|
},
|
||||||
wantWarn: "expected 64-4096",
|
wantWarn: "expected 64-4096",
|
||||||
|
|
@ -254,7 +250,6 @@ func TestValidate_MemoryConfig(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "unknown embedding provider",
|
name: "unknown embedding provider",
|
||||||
mutate: func(c *Config) {
|
mutate: func(c *Config) {
|
||||||
c.Memory.Enabled = true
|
|
||||||
c.Memory.Embedding.Provider = "nonexistent"
|
c.Memory.Embedding.Provider = "nonexistent"
|
||||||
},
|
},
|
||||||
wantWarn: "unknown",
|
wantWarn: "unknown",
|
||||||
|
|
@ -262,7 +257,6 @@ func TestValidate_MemoryConfig(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "openai without key",
|
name: "openai without key",
|
||||||
mutate: func(c *Config) {
|
mutate: func(c *Config) {
|
||||||
c.Memory.Enabled = true
|
|
||||||
c.Memory.Embedding.Provider = "openai"
|
c.Memory.Embedding.Provider = "openai"
|
||||||
},
|
},
|
||||||
wantWarn: "no API key found",
|
wantWarn: "no API key found",
|
||||||
|
|
@ -270,21 +264,11 @@ func TestValidate_MemoryConfig(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "valid openai with fallback key",
|
name: "valid openai with fallback key",
|
||||||
mutate: func(c *Config) {
|
mutate: func(c *Config) {
|
||||||
c.Memory.Enabled = true
|
|
||||||
c.Memory.Embedding.Provider = "openai"
|
c.Memory.Embedding.Provider = "openai"
|
||||||
c.Providers.OpenAI.APIKey = "sk-test"
|
c.Providers.OpenAI.APIKey = "sk-test"
|
||||||
},
|
},
|
||||||
wantWarn: "",
|
wantWarn: "",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "disabled memory skips all checks",
|
|
||||||
mutate: func(c *Config) {
|
|
||||||
c.Memory.Enabled = false
|
|
||||||
c.Memory.EmbeddingDims = -999
|
|
||||||
c.Memory.Sync.SyncURL = "bad"
|
|
||||||
},
|
|
||||||
wantWarn: "",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue