diff --git a/README.fr.md b/README.fr.md index d5fe873bf6..97dabe1256 100644 --- a/README.fr.md +++ b/README.fr.md @@ -653,11 +653,10 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/. ├── state/ # État persistant (dernier canal, etc.) ├── cron/ # Base de données des tâches planifiées ├── skills/ # Compétences personnalisées -├── AGENTS.md # Guide de comportement de l'Agent +├── AGENT.md # Définition structurée de l'agent et prompt système ├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min) -├── IDENTITY.md # Identité de l'Agent ├── SOUL.md # Âme de l'Agent -└── USER.md # Préférences utilisateur +└── ... ``` ### 🔒 Bac à Sable de Sécurité diff --git a/README.ja.md b/README.ja.md index 7fff46d13e..3f43e29add 100644 --- a/README.ja.md +++ b/README.ja.md @@ -617,11 +617,10 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw ├── state/ # 永続状態(最後のチャネルなど) ├── cron/ # スケジュールジョブデータベース ├── skills/ # カスタムスキル -├── AGENTS.md # エージェントの行動ガイド +├── AGENT.md # 構造化されたエージェント定義とシステムプロンプト ├── HEARTBEAT.md # 定期タスクプロンプト(30分ごとに確認) -├── IDENTITY.md # エージェントのアイデンティティ ├── SOUL.md # エージェントのソウル -└── USER.md # ユーザー設定 +└── ... ``` ### 🔒 セキュリティサンドボックス diff --git a/README.md b/README.md index e64daf0e4f..75ad7255a8 100644 --- a/README.md +++ b/README.md @@ -784,15 +784,15 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ``` ~/.picoclaw/workspace/ ├── sessions/ # Conversation sessions and history -├── memory/ # Long-term memory (MEMORY.md) -├── state/ # Persistent state (last channel, etc.) -├── cron/ # Scheduled jobs database -├── skills/ # Custom skills -├── AGENTS.md # Agent behavior guide -├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) -├── IDENTITY.md # Agent identity -├── SOUL.md # Agent soul -└── USER.md # User preferences +├── memory/ # Long-term memory (MEMORY.md) +├── state/ # Persistent state (last channel, etc.) +├── cron/ # Scheduled jobs database +├── skills/ # Workspace-specific skills +├── AGENT.md # Structured agent definition and system prompt +├── SOUL.md # Agent soul +├── USER.md # User profile and preferences for this workspace +├── HEARTBEAT.md # Periodic task prompts (checked every 30 min) +└── ... ``` ### Skill Sources diff --git a/README.pt-br.md b/README.pt-br.md index 3fe24d7eaf..fab8b8b0f8 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -649,11 +649,10 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp ├── state/ # Estado persistente (ultimo canal, etc.) ├── cron/ # Banco de dados de tarefas agendadas ├── skills/ # Skills personalizadas -├── AGENTS.md # Guia de comportamento do Agente +├── AGENT.md # Definicao estruturada do agente e prompt do sistema ├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) -├── IDENTITY.md # Identidade do Agente ├── SOUL.md # Alma do Agente -└── USER.md # Preferencias do usuario +└── ... ``` ### 🔒 Sandbox de Segurança diff --git a/README.vi.md b/README.vi.md index 3ee0209f6c..337e3d68a6 100644 --- a/README.vi.md +++ b/README.vi.md @@ -621,11 +621,10 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: ├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.) ├── cron/ # Cơ sở dữ liệu tác vụ định kỳ ├── skills/ # Kỹ năng tùy chỉnh -├── AGENTS.md # Hướng dẫn hành vi Agent +├── AGENT.md # Định nghĩa agent có cấu trúc và system prompt ├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) -├── IDENTITY.md # Danh tính Agent ├── SOUL.md # Tâm hồn/Tính cách Agent -└── USER.md # Tùy chọn người dùng +└── ... ``` ### 🔒 Hộp cát bảo mật (Security Sandbox) diff --git a/README.zh.md b/README.zh.md index 66d7c5f7cc..aba133eefc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -365,15 +365,15 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work ``` ~/.picoclaw/workspace/ ├── sessions/ # 对话会话和历史 -├── memory/ # 长期记忆 (MEMORY.md) -├── state/ # 持久化状态 (最后一次频道等) -├── cron/ # 定时任务数据库 -├── skills/ # 自定义技能 -├── AGENTS.md # Agent 行为指南 -├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) -├── IDENTITY.md # Agent 身份设定 -├── SOUL.md # Agent 灵魂/性格 -└── USER.md # 用户偏好 +├── memory/ # 长期记忆 (MEMORY.md) +├── state/ # 持久化状态 (最后一次频道等) +├── cron/ # 定时任务数据库 +├── skills/ # 工作区级技能 +├── AGENT.md # 结构化 Agent 定义与系统提示词 +├── SOUL.md # Agent 灵魂/性格 +├── USER.md # 当前工作区的用户资料与偏好 +├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次) +└── ... ``` diff --git a/cmd/picoclaw/internal/onboard/helpers_test.go b/cmd/picoclaw/internal/onboard/helpers_test.go index f3e0c92e08..23fc97c5a9 100644 --- a/cmd/picoclaw/internal/onboard/helpers_test.go +++ b/cmd/picoclaw/internal/onboard/helpers_test.go @@ -6,20 +6,32 @@ import ( "testing" ) -func TestCopyEmbeddedToTargetUsesAgentsMarkdown(t *testing.T) { +func TestCopyEmbeddedToTargetUsesStructuredAgentFiles(t *testing.T) { targetDir := t.TempDir() if err := copyEmbeddedToTarget(targetDir); err != nil { t.Fatalf("copyEmbeddedToTarget() error = %v", err) } - agentsPath := filepath.Join(targetDir, "AGENTS.md") - if _, err := os.Stat(agentsPath); err != nil { - t.Fatalf("expected %s to exist: %v", agentsPath, err) + agentPath := filepath.Join(targetDir, "AGENT.md") + if _, err := os.Stat(agentPath); err != nil { + t.Fatalf("expected %s to exist: %v", agentPath, err) } - legacyPath := filepath.Join(targetDir, "AGENT.md") - if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { - t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err) + soulPath := filepath.Join(targetDir, "SOUL.md") + if _, err := os.Stat(soulPath); err != nil { + t.Fatalf("expected %s to exist: %v", soulPath, err) + } + + userPath := filepath.Join(targetDir, "USER.md") + if _, err := os.Stat(userPath); err != nil { + t.Fatalf("expected %s to exist: %v", userPath, err) + } + + for _, legacyName := range []string{"AGENTS.md", "IDENTITY.md"} { + legacyPath := filepath.Join(targetDir, legacyName) + if _, err := os.Stat(legacyPath); !os.IsNotExist(err) { + t.Fatalf("expected legacy file %s to be absent, got err=%v", legacyPath, err) + } } } diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 5a84c45e22..cb566f02b6 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -222,13 +222,10 @@ func (cb *ContextBuilder) InvalidateCache() { // invalidation (bootstrap files + memory). Skill roots are handled separately // because they require both directory-level and recursive file-level checks. func (cb *ContextBuilder) sourcePaths() []string { - return []string{ - filepath.Join(cb.workspace, "AGENTS.md"), - filepath.Join(cb.workspace, "SOUL.md"), - filepath.Join(cb.workspace, "USER.md"), - filepath.Join(cb.workspace, "IDENTITY.md"), - filepath.Join(cb.workspace, "memory", "MEMORY.md"), - } + agentDefinition := cb.LoadAgentDefinition() + paths := agentDefinition.trackedPaths(cb.workspace) + paths = append(paths, filepath.Join(cb.workspace, "memory", "MEMORY.md")) + return uniquePaths(paths) } // skillRoots returns all skill root directories that can affect @@ -432,18 +429,32 @@ func skillFilesChangedSince(skillRoots []string, filesAtCache map[string]time.Ti } func (cb *ContextBuilder) LoadBootstrapFiles() string { - bootstrapFiles := []string{ - "AGENTS.md", - "SOUL.md", - "USER.md", - "IDENTITY.md", + var sb strings.Builder + + agentDefinition := cb.LoadAgentDefinition() + if agentDefinition.Agent != nil { + label := string(agentDefinition.Source) + if label == "" { + label = relativeWorkspacePath(cb.workspace, agentDefinition.Agent.Path) + } + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", label, agentDefinition.Agent.Body) + } + if agentDefinition.Soul != nil { + fmt.Fprintf( + &sb, + "## %s\n\n%s\n\n", + relativeWorkspacePath(cb.workspace, agentDefinition.Soul.Path), + agentDefinition.Soul.Content, + ) + } + if agentDefinition.User != nil { + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "USER.md", agentDefinition.User.Content) } - var sb strings.Builder - for _, filename := range bootstrapFiles { - filePath := filepath.Join(cb.workspace, filename) + if agentDefinition.Source != AgentDefinitionSourceAgent { + filePath := filepath.Join(cb.workspace, "IDENTITY.md") if data, err := os.ReadFile(filePath); err == nil { - fmt.Fprintf(&sb, "## %s\n\n%s\n\n", filename, data) + fmt.Fprintf(&sb, "## %s\n\n%s\n\n", "IDENTITY.md", data) } } diff --git a/pkg/agent/context_cache_test.go b/pkg/agent/context_cache_test.go index 707510820d..1f9423a3ac 100644 --- a/pkg/agent/context_cache_test.go +++ b/pkg/agent/context_cache_test.go @@ -37,7 +37,7 @@ func setupWorkspace(t *testing.T, files map[string]string) string { // Codex (only reads last system message as instructions). func TestSingleSystemMessage(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nTest agent.", + "AGENT.md": "# Agent\nTest agent.", }) defer os.RemoveAll(tmpDir) @@ -140,10 +140,10 @@ func TestMtimeAutoInvalidation(t *testing.T) { }{ { name: "bootstrap file change", - file: "IDENTITY.md", - contentV1: "# Original Identity", - contentV2: "# Updated Identity", - checkField: "Updated Identity", + file: "AGENT.md", + contentV1: "# Original Agent", + contentV2: "# Updated Agent", + checkField: "Updated Agent", }, { name: "memory file change", @@ -218,7 +218,7 @@ func TestMtimeAutoInvalidation(t *testing.T) { // even when source files haven't changed (useful for tests and reload commands). func TestExplicitInvalidateCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Test Identity", + "AGENT.md": "# Test Agent", }) defer os.RemoveAll(tmpDir) @@ -245,8 +245,8 @@ func TestExplicitInvalidateCache(t *testing.T) { // when no files change (regression test for issue #607). func TestCacheStability(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nContent", - "SOUL.md": "# Soul\nContent", + "AGENT.md": "# Agent\nContent", + "SOUL.md": "# Soul\nContent", }) defer os.RemoveAll(tmpDir) @@ -545,7 +545,7 @@ description: delete-me-v1 // Run with: go test -race ./pkg/agent/ -run TestConcurrentBuildSystemPromptWithCache func TestConcurrentBuildSystemPromptWithCache(t *testing.T) { tmpDir := setupWorkspace(t, map[string]string{ - "IDENTITY.md": "# Identity\nConcurrency test agent.", + "AGENT.md": "# Agent\nConcurrency test agent.", "SOUL.md": "# Soul\nBe helpful.", "memory/MEMORY.md": "# Memory\nUser prefers Go.", "skills/demo/SKILL.md": "---\nname: demo\ndescription: \"demo skill\"\n---\n# Demo", @@ -652,7 +652,7 @@ func BenchmarkBuildMessagesWithCache(b *testing.B) { os.MkdirAll(filepath.Join(tmpDir, "memory"), 0o755) os.MkdirAll(filepath.Join(tmpDir, "skills"), 0o755) - for _, name := range []string{"IDENTITY.md", "SOUL.md", "USER.md"} { + for _, name := range []string{"AGENT.md", "SOUL.md"} { os.WriteFile(filepath.Join(tmpDir, name), []byte(strings.Repeat("Content.\n", 10)), 0o644) } diff --git a/pkg/agent/definition.go b/pkg/agent/definition.go new file mode 100644 index 0000000000..cf73d607ce --- /dev/null +++ b/pkg/agent/definition.go @@ -0,0 +1,255 @@ +package agent + +import ( + "os" + "path/filepath" + "slices" + "strings" + + "github.com/gomarkdown/markdown/parser" + "gopkg.in/yaml.v3" + + "github.com/sipeed/picoclaw/pkg/logger" +) + +// AgentDefinitionSource identifies which agent bootstrap file produced the definition. +type AgentDefinitionSource string + +const ( + // AgentDefinitionSourceAgent indicates the new AGENT.md format. + AgentDefinitionSourceAgent AgentDefinitionSource = "AGENT.md" + // AgentDefinitionSourceAgents indicates the legacy AGENTS.md format. + AgentDefinitionSourceAgents AgentDefinitionSource = "AGENTS.md" +) + +// AgentFrontmatter holds machine-readable AGENT.md configuration. +// +// Known fields are exposed directly for convenience. Fields keeps the full +// parsed frontmatter so future refactors can read additional keys without +// changing the loader contract again. +type AgentFrontmatter struct { + Name string `json:"name"` + Description string `json:"description"` + Tools []string `json:"tools,omitempty"` + Model string `json:"model,omitempty"` + MaxTurns *int `json:"maxTurns,omitempty"` + Skills []string `json:"skills,omitempty"` + MCPServers []string `json:"mcpServers,omitempty"` + Fields map[string]any `json:"fields,omitempty"` +} + +// AgentPromptDefinition represents the parsed AGENT.md or AGENTS.md prompt file. +type AgentPromptDefinition struct { + Path string `json:"path"` + Raw string `json:"raw"` + Body string `json:"body"` + RawFrontmatter string `json:"raw_frontmatter,omitempty"` + Frontmatter AgentFrontmatter `json:"frontmatter"` +} + +// SoulDefinition represents the resolved SOUL.md file linked to the agent. +type SoulDefinition struct { + Path string `json:"path"` + Content string `json:"content"` +} + +// UserDefinition represents the resolved USER.md file linked to the workspace. +type UserDefinition struct { + Path string `json:"path"` + Content string `json:"content"` +} + +// AgentContextDefinition captures the workspace agent definition in a runtime-friendly shape. +type AgentContextDefinition struct { + Source AgentDefinitionSource `json:"source,omitempty"` + Agent *AgentPromptDefinition `json:"agent,omitempty"` + Soul *SoulDefinition `json:"soul,omitempty"` + User *UserDefinition `json:"user,omitempty"` +} + +// LoadAgentDefinition parses the workspace agent bootstrap files. +// +// It prefers the new AGENT.md format and its paired SOUL.md file. When the +// structured files are absent, it falls back to the legacy AGENTS.md layout so +// the current runtime can transition incrementally. +func (cb *ContextBuilder) LoadAgentDefinition() AgentContextDefinition { + return loadAgentDefinition(cb.workspace) +} + +func loadAgentDefinition(workspace string) AgentContextDefinition { + definition := AgentContextDefinition{} + definition.User = loadUserDefinition(workspace) + agentPath := filepath.Join(workspace, string(AgentDefinitionSourceAgent)) + if content, err := os.ReadFile(agentPath); err == nil { + prompt := parseAgentPromptDefinition(agentPath, string(content)) + definition.Source = AgentDefinitionSourceAgent + definition.Agent = &prompt + soulPath := filepath.Join(workspace, "SOUL.md") + if content, err := os.ReadFile(soulPath); err == nil { + definition.Soul = &SoulDefinition{ + Path: soulPath, + Content: string(content), + } + } + return definition + } + + legacyPath := filepath.Join(workspace, string(AgentDefinitionSourceAgents)) + if content, err := os.ReadFile(legacyPath); err == nil { + definition.Source = AgentDefinitionSourceAgents + definition.Agent = &AgentPromptDefinition{ + Path: legacyPath, + Raw: string(content), + Body: string(content), + } + } + + defaultSoulPath := filepath.Join(workspace, "SOUL.md") + if definition.Source != "" || fileExists(defaultSoulPath) { + if content, err := os.ReadFile(defaultSoulPath); err == nil { + definition.Soul = &SoulDefinition{ + Path: defaultSoulPath, + Content: string(content), + } + } + } + + return definition +} + +func (definition AgentContextDefinition) trackedPaths(workspace string) []string { + paths := []string{ + filepath.Join(workspace, string(AgentDefinitionSourceAgent)), + filepath.Join(workspace, "SOUL.md"), + filepath.Join(workspace, "USER.md"), + } + if definition.Source != AgentDefinitionSourceAgent { + paths = append(paths, + filepath.Join(workspace, string(AgentDefinitionSourceAgents)), + filepath.Join(workspace, "IDENTITY.md"), + ) + } + return uniquePaths(paths) +} + +func loadUserDefinition(workspace string) *UserDefinition { + userPath := filepath.Join(workspace, "USER.md") + if content, err := os.ReadFile(userPath); err == nil { + return &UserDefinition{ + Path: userPath, + Content: string(content), + } + } + + return nil +} + +func parseAgentPromptDefinition(path, content string) AgentPromptDefinition { + frontmatter, body := splitAgentFrontmatter(content) + return AgentPromptDefinition{ + Path: path, + Raw: content, + Body: body, + RawFrontmatter: frontmatter, + Frontmatter: parseAgentFrontmatter(path, frontmatter), + } +} + +func parseAgentFrontmatter(path, frontmatter string) AgentFrontmatter { + frontmatter = strings.TrimSpace(frontmatter) + if frontmatter == "" { + return AgentFrontmatter{} + } + + rawFields := make(map[string]any) + if err := yaml.Unmarshal([]byte(frontmatter), &rawFields); err != nil { + logger.WarnCF("agent", "Failed to parse AGENT.md frontmatter", map[string]any{ + "path": path, + "error": err.Error(), + }) + return AgentFrontmatter{} + } + + var typed struct { + Name string `yaml:"name"` + Description string `yaml:"description"` + Tools []string `yaml:"tools"` + Model string `yaml:"model"` + MaxTurns *int `yaml:"maxTurns"` + Skills []string `yaml:"skills"` + MCPServers []string `yaml:"mcpServers"` + } + if err := yaml.Unmarshal([]byte(frontmatter), &typed); err != nil { + logger.WarnCF("agent", "Failed to decode AGENT.md frontmatter fields", map[string]any{ + "path": path, + "error": err.Error(), + }) + return AgentFrontmatter{} + } + + return AgentFrontmatter{ + Name: strings.TrimSpace(typed.Name), + Description: strings.TrimSpace(typed.Description), + Tools: append([]string(nil), typed.Tools...), + Model: strings.TrimSpace(typed.Model), + MaxTurns: typed.MaxTurns, + Skills: append([]string(nil), typed.Skills...), + MCPServers: append([]string(nil), typed.MCPServers...), + Fields: rawFields, + } +} + +func splitAgentFrontmatter(content string) (frontmatter, body string) { + normalized := string(parser.NormalizeNewlines([]byte(content))) + lines := strings.Split(normalized, "\n") + if len(lines) == 0 || lines[0] != "---" { + return "", content + } + + end := -1 + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + end = i + break + } + } + if end == -1 { + return "", content + } + + frontmatter = strings.Join(lines[1:end], "\n") + body = strings.Join(lines[end+1:], "\n") + body = strings.TrimLeft(body, "\n") + return frontmatter, body +} + +func relativeWorkspacePath(workspace, path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + relativePath, err := filepath.Rel(workspace, path) + if err == nil && relativePath != "." && !strings.HasPrefix(relativePath, "..") { + return filepath.ToSlash(relativePath) + } + return filepath.Clean(path) +} + +func uniquePaths(paths []string) []string { + result := make([]string, 0, len(paths)) + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + cleaned := filepath.Clean(path) + if slices.Contains(result, cleaned) { + continue + } + result = append(result, cleaned) + } + return result +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/pkg/agent/definition_test.go b/pkg/agent/definition_test.go new file mode 100644 index 0000000000..5ee9969675 --- /dev/null +++ b/pkg/agent/definition_test.go @@ -0,0 +1,302 @@ +package agent + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadAgentDefinitionParsesFrontmatterAndSoul(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: pico +description: Structured agent +model: claude-3-7-sonnet +tools: + - shell + - search +maxTurns: 8 +skills: + - review + - search-docs +mcpServers: + - github +metadata: + mode: strict +--- +# Agent + +Act directly and use tools first. +`, + "SOUL.md": "# Soul\nStay precise.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.Source != AgentDefinitionSourceAgent { + t.Fatalf("expected source %q, got %q", AgentDefinitionSourceAgent, definition.Source) + } + if definition.Agent == nil { + t.Fatal("expected AGENT.md definition to be loaded") + } + if definition.Agent.Body == "" || !strings.Contains(definition.Agent.Body, "Act directly") { + t.Fatalf("expected AGENT.md body to be preserved, got %q", definition.Agent.Body) + } + if definition.Agent.Frontmatter.Name != "pico" { + t.Fatalf("expected name to be parsed, got %q", definition.Agent.Frontmatter.Name) + } + if definition.Agent.Frontmatter.Model != "claude-3-7-sonnet" { + t.Fatalf("expected model to be parsed, got %q", definition.Agent.Frontmatter.Model) + } + if len(definition.Agent.Frontmatter.Tools) != 2 { + t.Fatalf("expected tools to be parsed, got %v", definition.Agent.Frontmatter.Tools) + } + if definition.Agent.Frontmatter.MaxTurns == nil || *definition.Agent.Frontmatter.MaxTurns != 8 { + t.Fatalf("expected maxTurns to be parsed, got %v", definition.Agent.Frontmatter.MaxTurns) + } + if len(definition.Agent.Frontmatter.Skills) != 2 { + t.Fatalf("expected skills to be parsed, got %v", definition.Agent.Frontmatter.Skills) + } + if len(definition.Agent.Frontmatter.MCPServers) != 1 || definition.Agent.Frontmatter.MCPServers[0] != "github" { + t.Fatalf("expected mcpServers to be parsed, got %v", definition.Agent.Frontmatter.MCPServers) + } + if definition.Agent.Frontmatter.Fields["metadata"] == nil { + t.Fatal("expected arbitrary frontmatter fields to remain available") + } + + if definition.Soul == nil { + t.Fatal("expected SOUL.md to be loaded") + } + if !strings.Contains(definition.Soul.Content, "Stay precise") { + t.Fatalf("expected soul content to be loaded, got %q", definition.Soul.Content) + } + if definition.Soul.Path != filepath.Join(tmpDir, "SOUL.md") { + t.Fatalf("expected default SOUL.md path, got %q", definition.Soul.Path) + } +} + +func TestLoadAgentDefinitionFallsBackToLegacyAgentsMarkdown(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENTS.md": "# Legacy Agent\nKeep compatibility.", + "SOUL.md": "# Soul\nLegacy soul.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.Source != AgentDefinitionSourceAgents { + t.Fatalf("expected source %q, got %q", AgentDefinitionSourceAgents, definition.Source) + } + if definition.Agent == nil { + t.Fatal("expected AGENTS.md to be loaded") + } + if definition.Agent.RawFrontmatter != "" { + t.Fatalf("legacy AGENTS.md should not have frontmatter, got %q", definition.Agent.RawFrontmatter) + } + if !strings.Contains(definition.Agent.Body, "Keep compatibility") { + t.Fatalf("expected legacy body to be preserved, got %q", definition.Agent.Body) + } + if definition.Soul == nil || !strings.Contains(definition.Soul.Content, "Legacy soul") { + t.Fatal("expected default SOUL.md to be loaded for legacy format") + } +} + +func TestLoadAgentDefinitionLoadsWorkspaceUserMarkdown(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nStructured agent.", + "USER.md": "# User\nWorkspace preferences.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.User == nil { + t.Fatal("expected USER.md to be loaded") + } + if definition.User.Path != filepath.Join(tmpDir, "USER.md") { + t.Fatalf("expected workspace USER.md path, got %q", definition.User.Path) + } + if !strings.Contains(definition.User.Content, "Workspace preferences") { + t.Fatalf("expected workspace USER.md content, got %q", definition.User.Content) + } +} + +func TestLoadAgentDefinitionInvalidFrontmatterFallsBackToEmptyStructuredFields(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: pico +tools: + - shell + broken +--- +# Agent + +Keep going. +`, + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + definition := cb.LoadAgentDefinition() + + if definition.Agent == nil { + t.Fatal("expected AGENT.md definition to be loaded") + } + if !strings.Contains(definition.Agent.Body, "Keep going.") { + t.Fatalf("expected AGENT.md body to be preserved, got %q", definition.Agent.Body) + } + if definition.Agent.Frontmatter.Name != "" || + definition.Agent.Frontmatter.Description != "" || + definition.Agent.Frontmatter.Model != "" || + definition.Agent.Frontmatter.MaxTurns != nil || + len(definition.Agent.Frontmatter.Tools) != 0 || + len(definition.Agent.Frontmatter.Skills) != 0 || + len(definition.Agent.Frontmatter.MCPServers) != 0 || + len(definition.Agent.Frontmatter.Fields) != 0 { + t.Fatalf("expected invalid frontmatter to decode as empty struct, got %+v", definition.Agent.Frontmatter) + } +} + +func TestLoadBootstrapFilesUsesAgentBodyNotFrontmatter(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": `--- +name: pico +model: codex-mini +--- +# Agent + +Follow the body prompt. +`, + "SOUL.md": "# Soul\nSpeak plainly.", + "IDENTITY.md": "# Identity\nWorkspace identity.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + bootstrap := cb.LoadBootstrapFiles() + + if !strings.Contains(bootstrap, "Follow the body prompt") { + t.Fatalf("expected AGENT.md body in bootstrap, got %q", bootstrap) + } + if !strings.Contains(bootstrap, "Speak plainly") { + t.Fatalf("expected resolved soul content in bootstrap, got %q", bootstrap) + } + if strings.Contains(bootstrap, "name: pico") { + t.Fatalf("bootstrap should not expose raw frontmatter, got %q", bootstrap) + } + if strings.Contains(bootstrap, "model: codex-mini") { + t.Fatalf("bootstrap should not expose raw frontmatter, got %q", bootstrap) + } + if !strings.Contains(bootstrap, "SOUL.md") { + t.Fatalf("expected bootstrap to label SOUL.md, got %q", bootstrap) + } + if strings.Contains(bootstrap, "Workspace identity") { + t.Fatalf("structured bootstrap should ignore IDENTITY.md, got %q", bootstrap) + } +} + +func TestLoadBootstrapFilesIncludesWorkspaceUserMarkdown(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nFollow the new structure.", + "SOUL.md": "# Soul\nSpeak plainly.", + "USER.md": "# User\nShared profile.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + bootstrap := cb.LoadBootstrapFiles() + + if !strings.Contains(bootstrap, "Shared profile") { + t.Fatalf("expected workspace USER.md in bootstrap, got %q", bootstrap) + } + if !strings.Contains(bootstrap, "## USER.md") { + t.Fatalf("expected USER.md heading in bootstrap, got %q", bootstrap) + } +} + +func TestStructuredAgentIgnoresIdentityChanges(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nFollow the new structure.", + "SOUL.md": "# Soul\nVersion one.", + "IDENTITY.md": "# Identity\nLegacy identity.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + + promptV1 := cb.BuildSystemPromptWithCache() + if strings.Contains(promptV1, "Legacy identity") { + t.Fatalf("structured prompt should not include IDENTITY.md, got %q", promptV1) + } + + identityPath := filepath.Join(tmpDir, "IDENTITY.md") + if err := os.WriteFile(identityPath, []byte("# Identity\nVersion two."), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(identityPath, future, future); err != nil { + t.Fatal(err) + } + + cb.systemPromptMutex.RLock() + changed := cb.sourceFilesChangedLocked() + cb.systemPromptMutex.RUnlock() + if changed { + t.Fatal("IDENTITY.md should not invalidate cache for structured agent definitions") + } + + promptV2 := cb.BuildSystemPromptWithCache() + if promptV1 != promptV2 { + t.Fatal("structured prompt should remain stable after IDENTITY.md changes") + } +} + +func TestStructuredAgentUserChangesInvalidateCache(t *testing.T) { + tmpDir := setupWorkspace(t, map[string]string{ + "AGENT.md": "# Agent\nFollow the new structure.", + "SOUL.md": "# Soul\nVersion one.", + "USER.md": "# User\nInitial workspace preferences.", + }) + defer cleanupWorkspace(t, tmpDir) + + cb := NewContextBuilder(tmpDir) + + promptV1 := cb.BuildSystemPromptWithCache() + if !strings.Contains(promptV1, "Initial workspace preferences") { + t.Fatalf("expected workspace USER.md in prompt, got %q", promptV1) + } + + userPath := filepath.Join(tmpDir, "USER.md") + if err := os.WriteFile(userPath, []byte("# User\nUpdated workspace preferences."), 0o644); err != nil { + t.Fatal(err) + } + future := time.Now().Add(2 * time.Second) + if err := os.Chtimes(userPath, future, future); err != nil { + t.Fatal(err) + } + + cb.systemPromptMutex.RLock() + changed := cb.sourceFilesChangedLocked() + cb.systemPromptMutex.RUnlock() + if !changed { + t.Fatal("workspace USER.md changes should invalidate cache") + } + + promptV2 := cb.BuildSystemPromptWithCache() + if !strings.Contains(promptV2, "Updated workspace preferences") { + t.Fatalf("expected updated workspace USER.md in prompt, got %q", promptV2) + } +} + +func cleanupWorkspace(t *testing.T, path string) { + t.Helper() + if err := os.RemoveAll(path); err != nil { + t.Fatalf("failed to clean up workspace %s: %v", path, err) + } +} diff --git a/workspace/AGENT.md b/workspace/AGENT.md new file mode 100644 index 0000000000..08f55a1b7d --- /dev/null +++ b/workspace/AGENT.md @@ -0,0 +1,45 @@ +--- +name: pico +description: > + The default general-purpose assistant for everyday conversation, problem + solving, and workspace help. +--- + +You are Pico, the default assistant for this workspace. +Your name is PicoClaw 🦞. +## Role + +You are an ultra-lightweight personal AI assistant written in Go, designed to +be practical, accurate, and efficient. + +## Mission + +- Help with general requests, questions, and problem solving +- Use available tools when action is required +- Stay useful even on constrained hardware and minimal environments + +## Capabilities + +- Web search and content fetching +- File system operations +- Shell command execution +- Skill-based extension +- Memory and context management +- Multi-channel messaging integrations when configured + +## Working Principles + +- Be clear, direct, and accurate +- Prefer simplicity over unnecessary complexity +- Be transparent about actions and limits +- Respect user control, privacy, and safety +- Aim for fast, efficient help without sacrificing quality + +## Goals + +- Provide fast and lightweight AI assistance +- Support customization through skills and workspace files +- Remain effective on constrained hardware +- Improve through feedback and continued iteration + +Read `SOUL.md` as part of your identity and communication style. diff --git a/workspace/AGENTS.md b/workspace/AGENTS.md deleted file mode 100644 index 5f5fa64804..0000000000 --- a/workspace/AGENTS.md +++ /dev/null @@ -1,12 +0,0 @@ -# Agent Instructions - -You are a helpful AI assistant. Be concise, accurate, and friendly. - -## Guidelines - -- Always explain what you're doing before taking actions -- Ask for clarification when request is ambiguous -- Use tools to help accomplish tasks -- Remember important information in your memory files -- Be proactive and helpful -- Learn from user feedback \ No newline at end of file diff --git a/workspace/IDENTITY.md b/workspace/IDENTITY.md deleted file mode 100644 index 20e3e49fab..0000000000 --- a/workspace/IDENTITY.md +++ /dev/null @@ -1,53 +0,0 @@ -# Identity - -## Name -PicoClaw 🦞 - -## Description -Ultra-lightweight personal AI assistant written in Go, inspired by nanobot. - -## Purpose -- Provide intelligent AI assistance with minimal resource usage -- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.) -- Enable easy customization through skills system -- Run on minimal hardware ($10 boards, <10MB RAM) - -## Capabilities - -- Web search and content fetching -- File system operations (read, write, edit) -- Shell command execution -- Multi-channel messaging (Telegram, WhatsApp, Feishu) -- Skill-based extensibility -- Memory and context management - -## Philosophy - -- Simplicity over complexity -- Performance over features -- User control and privacy -- Transparent operation -- Community-driven development - -## Goals - -- Provide a fast, lightweight AI assistant -- Support offline-first operation where possible -- Enable easy customization and extension -- Maintain high quality responses -- Run efficiently on constrained hardware - -## License -MIT License - Free and open source - -## Repository -https://github.com/sipeed/picoclaw - -## Contact -Issues: https://github.com/sipeed/picoclaw/issues -Discussions: https://github.com/sipeed/picoclaw/discussions - ---- - -"Every bit helps, every bit matters." -- Picoclaw \ No newline at end of file diff --git a/workspace/SOUL.md b/workspace/SOUL.md index 0be8834f57..8a6371ff96 100644 --- a/workspace/SOUL.md +++ b/workspace/SOUL.md @@ -1,6 +1,6 @@ # Soul -I am picoclaw, a lightweight AI assistant powered by AI. +I am PicoClaw: calm, helpful, and practical. ## Personality @@ -8,10 +8,12 @@ I am picoclaw, a lightweight AI assistant powered by AI. - Concise and to the point - Curious and eager to learn - Honest and transparent +- Calm under uncertainty ## Values - Accuracy over speed - User privacy and safety - Transparency in actions -- Continuous improvement \ No newline at end of file +- Continuous improvement +- Simplicity over unnecessary complexity diff --git a/workspace/USER.md b/workspace/USER.md index 91398a0194..9a3419d870 100644 --- a/workspace/USER.md +++ b/workspace/USER.md @@ -1,6 +1,6 @@ # User -Information about user goes here. +Information about the user goes here. ## Preferences @@ -18,4 +18,4 @@ Information about user goes here. - What the user wants to learn from AI - Preferred interaction style -- Areas of interest \ No newline at end of file +- Areas of interest