diff --git a/pkg/recode/recoder.go b/pkg/recode/recoder.go new file mode 100644 index 0000000000..a621220ca0 --- /dev/null +++ b/pkg/recode/recoder.go @@ -0,0 +1,215 @@ +package recode + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/GemachDAO/Gclaw/pkg/config" +) + +// RecodeAction records a single self-modification applied by the agent. +type RecodeAction struct { + Type string `json:"type"` // "prompt", "cron", "skill", "trading_param" + Details string `json:"details"` + Timestamp int64 `json:"timestamp"` + Approved bool `json:"approved"` // self-approved via goodwill +} + +// Recoder allows the agent to modify its own configuration +// when it has earned sufficient goodwill. +type Recoder struct { + configPath string + workspace string + actionLog []RecodeAction + mu sync.Mutex +} + +// NewRecoder creates a Recoder for the given config file and workspace. +func NewRecoder(configPath, workspace string) *Recoder { + return &Recoder{ + configPath: configPath, + workspace: workspace, + actionLog: []RecodeAction{}, + } +} + +// ModifySystemPrompt appends `addition` to the system prompt of every agent in config. +func (rc *Recoder) ModifySystemPrompt(addition string) error { + rc.mu.Lock() + defer rc.mu.Unlock() + + cfg, err := config.LoadConfig(rc.configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + for i := range cfg.Agents.List { + cfg.Agents.List[i].Name = cfg.Agents.List[i].Name // no-op; prompt is stored externally + } + // Append a marker comment to the defaults workspace path for persistence of prompt note. + // The actual system prompt is maintained by the agent loop from a separate file; + // we record the action here and write the addition to a prompt-additions file. + additionPath := rc.workspace + "/recode/prompt_additions.txt" + if err := appendToFile(additionPath, addition+"\n"); err != nil { + return fmt.Errorf("write prompt addition: %w", err) + } + + if err := config.SaveConfig(rc.configPath, cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + rc.logAction("prompt", addition) + return nil +} + +// AddCronJob appends a new cron entry to the agent's config. +// schedule must be a valid cron expression; task is a natural-language description. +func (rc *Recoder) AddCronJob(schedule, task string) error { + rc.mu.Lock() + defer rc.mu.Unlock() + + cfg, err := config.LoadConfig(rc.configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + if cfg.Tools.Cron.ExecTimeoutMinutes == 0 { + cfg.Tools.Cron.ExecTimeoutMinutes = 5 // sensible default + } + + if err := config.SaveConfig(rc.configPath, cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + // Record the cron job in a dedicated file for the cron tool to pick up. + cronEntry := fmt.Sprintf("%s\t%s\n", schedule, task) + cronPath := rc.workspace + "/recode/cron_additions.txt" + if err := appendToFile(cronPath, cronEntry); err != nil { + return fmt.Errorf("write cron addition: %w", err) + } + + rc.logAction("cron", fmt.Sprintf("schedule=%s task=%s", schedule, task)) + return nil +} + +// InstallSkill records a skill installation request that the skills tool can act on. +func (rc *Recoder) InstallSkill(skillSlug, registry string) error { + rc.mu.Lock() + defer rc.mu.Unlock() + + installPath := rc.workspace + "/recode/skill_installs.txt" + entry := fmt.Sprintf("%s\t%s\n", registry, skillSlug) + if err := appendToFile(installPath, entry); err != nil { + return fmt.Errorf("write skill install request: %w", err) + } + + rc.logAction("skill", fmt.Sprintf("slug=%s registry=%s", skillSlug, registry)) + return nil +} + +// AdjustTradingParams merges params into the GDEX config section and saves. +func (rc *Recoder) AdjustTradingParams(params map[string]any) error { + rc.mu.Lock() + defer rc.mu.Unlock() + + cfg, err := config.LoadConfig(rc.configPath) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + // Apply params to GDEXConfig fields by name. + if v, ok := params["max_trade_size_sol"]; ok { + if f, ok := toFloat64(v); ok { + cfg.Tools.GDEX.MaxTradeSizeSOL = f + } + } + if v, ok := params["auto_trade"]; ok { + if b, ok := v.(bool); ok { + cfg.Tools.GDEX.AutoTrade = b + } + } + if v, ok := params["default_chain_id"]; ok { + if f, ok := toFloat64(v); ok { + cfg.Tools.GDEX.DefaultChainID = int64(f) + } + } + + if err := config.SaveConfig(rc.configPath, cfg); err != nil { + return fmt.Errorf("save config: %w", err) + } + + details, _ := json.Marshal(params) + rc.logAction("trading_param", string(details)) + return nil +} + +// GetActionLog returns a copy of all recorded recode actions. +func (rc *Recoder) GetActionLog() []RecodeAction { + rc.mu.Lock() + defer rc.mu.Unlock() + out := make([]RecodeAction, len(rc.actionLog)) + copy(out, rc.actionLog) + return out +} + +// Rollback reverts the action at actionIndex by recording a reversal note. +// Full undo of arbitrary config changes is complex; this implementation marks +// the action as un-approved and records a rollback entry. +func (rc *Recoder) Rollback(actionIndex int) error { + rc.mu.Lock() + defer rc.mu.Unlock() + + if actionIndex < 0 || actionIndex >= len(rc.actionLog) { + return fmt.Errorf("invalid action index %d (log has %d entries)", actionIndex, len(rc.actionLog)) + } + + original := rc.actionLog[actionIndex] + rc.actionLog[actionIndex].Approved = false + rc.logAction("rollback", fmt.Sprintf("rolled back action %d (type=%s)", actionIndex, original.Type)) + return nil +} + +// logAction appends an approved RecodeAction. Caller must hold rc.mu. +func (rc *Recoder) logAction(typ, details string) { + rc.actionLog = append(rc.actionLog, RecodeAction{ + Type: typ, + Details: details, + Timestamp: time.Now().UnixMilli(), + Approved: true, + }) +} + +// appendToFile ensures the directory exists and appends content to a file. +func appendToFile(path, content string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return err + } + defer f.Close() + _, err = f.WriteString(content) + return err +} + +// toFloat64 attempts to convert an any value to float64. +func toFloat64(v any) (float64, bool) { + switch val := v.(type) { + case float64: + return val, true + case int: + return float64(val), true + case int64: + return float64(val), true + case json.Number: + f, err := val.Float64() + return f, err == nil + } + return 0, false +} diff --git a/pkg/replication/mutation.go b/pkg/replication/mutation.go new file mode 100644 index 0000000000..cc5ab52c44 --- /dev/null +++ b/pkg/replication/mutation.go @@ -0,0 +1,26 @@ +package replication + +import "math/rand" + +// mutationPool holds possible prompt mutations for child agents. +var mutationPool = []string{ + "You prefer high-frequency small trades over large positions.", + "You are contrarian — buy when others sell, sell when others buy.", + "You focus exclusively on newly launched tokens under 1 hour old.", + "You prioritize tokens with high liquidity and low volatility.", + "You are a momentum trader — chase tokens with >50% gains in 1 hour.", + "You specialize in copy trading the top 3 performers.", + "You are risk-averse — never risk more than 1% of balance per trade.", + "You are aggressive — willing to risk 10% of balance for high-conviction trades.", +} + +// mutateSystemPrompt takes a parent system prompt and appends a randomly selected +// trading strategy mutation to create diversity among child agents. +// Note: math/rand is automatically seeded in Go 1.20+ so no explicit seeding is needed. +func mutateSystemPrompt(parentPrompt string) string { + mutation := mutationPool[rand.Intn(len(mutationPool))] //nolint:gosec + if parentPrompt == "" { + return mutation + } + return parentPrompt + "\n\n" + mutation +} diff --git a/pkg/replication/persistence.go b/pkg/replication/persistence.go new file mode 100644 index 0000000000..2a3545afc0 --- /dev/null +++ b/pkg/replication/persistence.go @@ -0,0 +1,53 @@ +package replication + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// SaveChildren persists the current children list to +// {workspace}/replication/children.json using an atomic write. +func (r *Replicator) SaveChildren(workspace string) error { + r.mu.RLock() + data, err := json.MarshalIndent(r.children, "", " ") + r.mu.RUnlock() + if err != nil { + return err + } + + dir := filepath.Join(workspace, "replication") + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + + path := filepath.Join(dir, "children.json") + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// LoadChildren restores the children list from +// {workspace}/replication/children.json. Missing file is treated as empty. +func (r *Replicator) LoadChildren(workspace string) error { + path := filepath.Join(workspace, "replication", "children.json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var children []*ChildAgent + if err := json.Unmarshal(data, &children); err != nil { + return err + } + + r.mu.Lock() + r.children = children + r.mu.Unlock() + return nil +} diff --git a/pkg/replication/replicator.go b/pkg/replication/replicator.go new file mode 100644 index 0000000000..e87a6957ea --- /dev/null +++ b/pkg/replication/replicator.go @@ -0,0 +1,259 @@ +package replication + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" + + "github.com/GemachDAO/Gclaw/pkg/config" +) + +// ReplicationConfig holds settings for the agent self-replication feature. +type ReplicationConfig struct { + Enabled bool `json:"enabled"` + MaxChildren int `json:"max_children"` // max child agents (default 3) + GMACSharePercent float64 `json:"gmac_share_percent"` // % of parent GMAC given to child (default 50) + MutatePrompt bool `json:"mutate_prompt"` // allow prompt mutation in children (default true) + InheritSkills bool `json:"inherit_skills"` // copy parent skills to child (default true) + InheritMemory bool `json:"inherit_memory"` // copy parent memory to child (default true) + ChildWorkspaceDir string `json:"child_workspace_dir"` // base dir for child workspaces +} + +// ChildAgent represents a replicated child agent instance. +type ChildAgent struct { + ID string `json:"id"` + ParentID string `json:"parent_id"` + Generation int `json:"generation"` + WorkspacePath string `json:"workspace_path"` + ConfigPath string `json:"config_path"` + CreatedAt int64 `json:"created_at"` + GMACBalance float64 `json:"initial_gmac"` + Status string `json:"status"` + Mutations []string `json:"mutations"` +} + +// Replicator handles spawning persistent child Gclaw agents +// that inherit config, skills, and memory from the parent. +type Replicator struct { + config ReplicationConfig + children []*ChildAgent + parentID string + mu sync.RWMutex +} + +// NewReplicator creates a new Replicator for the given parent agent. +func NewReplicator(parentID string, cfg ReplicationConfig) *Replicator { + if cfg.MaxChildren == 0 { + cfg.MaxChildren = 3 + } + if cfg.GMACSharePercent == 0 { + cfg.GMACSharePercent = 50 + } + return &Replicator{ + config: cfg, + children: []*ChildAgent{}, + parentID: parentID, + } +} + +// CanReplicate returns true if goodwill meets or exceeds the replication threshold. +func (r *Replicator) CanReplicate(goodwill int, threshold int) bool { + return goodwill >= threshold +} + +// GoodwillSource is an optional interface for retrieving the current goodwill score. +// This allows the tools layer to pass metabolism without importing it directly. +type GoodwillSource interface { + GetGoodwill() int +} + +// Replicate creates a new child agent, copying config, skills, and memory +// from the parent. It returns the ChildAgent descriptor or an error. +// parentGMAC is passed by pointer so the share can be deducted in-place. +func (r *Replicator) Replicate(parentConfig *config.Config, parentWorkspace string, parentGMAC *float64) (*ChildAgent, error) { + r.mu.Lock() + defer r.mu.Unlock() + + if !r.config.Enabled { + return nil, fmt.Errorf("replication is disabled") + } + if len(r.children) >= r.config.MaxChildren { + return nil, fmt.Errorf("max children (%d) already reached", r.config.MaxChildren) + } + + ts := time.Now() + childID := fmt.Sprintf("gclaw-child-%d", ts.UnixMilli()) + + baseDir := r.config.ChildWorkspaceDir + if baseDir == "" { + baseDir = filepath.Join(parentWorkspace, "children") + } + childWorkspace := filepath.Join(baseDir, childID) + if err := os.MkdirAll(childWorkspace, 0o755); err != nil { + return nil, fmt.Errorf("create child workspace: %w", err) + } + + // Deep-copy parent config via JSON round-trip + childCfg, err := deepCopyConfig(parentConfig) + if err != nil { + return nil, fmt.Errorf("copy config: %w", err) + } + + // Set child workspace + childCfg.Agents.Defaults.Workspace = childWorkspace + + // Assign a unique gateway port (parent port + child index) + childCfg.Gateway.Port = parentConfig.Gateway.Port + len(r.children) + 1 + + var mutations []string + + // Mutate system prompt if enabled — write mutation to child workspace + // so the agent loop picks it up via LoadBootstrapFiles. + if r.config.MutatePrompt { + mutation := mutateSystemPrompt("") + mutations = append(mutations, mutation) + strategyPath := filepath.Join(childWorkspace, "TRADING_STRATEGY.md") + _ = os.WriteFile(strategyPath, []byte("## Trading Strategy\n\n"+mutation+"\n"), 0o600) + } + + // Copy skills directory + if r.config.InheritSkills { + src := filepath.Join(parentWorkspace, "skills") + dst := filepath.Join(childWorkspace, "skills") + _ = copyDir(src, dst) + } + + // Copy memory directory + if r.config.InheritMemory { + src := filepath.Join(parentWorkspace, "memory") + dst := filepath.Join(childWorkspace, "memory") + _ = copyDir(src, dst) + } + + // Split GMAC balance + share := *parentGMAC * (r.config.GMACSharePercent / 100.0) + *parentGMAC -= share + + // Save child config + childConfigPath := filepath.Join(childWorkspace, "config.json") + if err := config.SaveConfig(childConfigPath, childCfg); err != nil { + return nil, fmt.Errorf("save child config: %w", err) + } + + child := &ChildAgent{ + ID: childID, + ParentID: r.parentID, + Generation: 1, + WorkspacePath: childWorkspace, + ConfigPath: childConfigPath, + CreatedAt: ts.UnixMilli(), + GMACBalance: share, + Status: "running", + Mutations: mutations, + } + + r.children = append(r.children, child) + return child, nil +} + +// ListChildren returns a snapshot of all child agents. +func (r *Replicator) ListChildren() []*ChildAgent { + r.mu.RLock() + defer r.mu.RUnlock() + out := make([]*ChildAgent, len(r.children)) + copy(out, r.children) + return out +} + +// GetChild returns the child agent with the given ID, or false if not found. +func (r *Replicator) GetChild(id string) (*ChildAgent, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + for _, c := range r.children { + if c.ID == id { + return c, true + } + } + return nil, false +} + +// StopChild marks a child agent as stopped. +func (r *Replicator) StopChild(id string) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, c := range r.children { + if c.ID == id { + c.Status = "stopped" + return nil + } + } + return fmt.Errorf("child %q not found", id) +} + +// deepCopyConfig performs a deep copy of the config via JSON marshaling. +func deepCopyConfig(src *config.Config) (*config.Config, error) { + data, err := json.Marshal(src) + if err != nil { + return nil, err + } + var dst config.Config + if err := json.Unmarshal(data, &dst); err != nil { + return nil, err + } + return &dst, nil +} + +// copyDir recursively copies src directory to dst. Errors are silently ignored +// for missing source directories (inheriting nothing is valid). +func copyDir(src, dst string) error { + info, err := os.Stat(src) + if err != nil { + return nil // source does not exist, skip + } + if !info.IsDir() { + return nil + } + if err := os.MkdirAll(dst, 0o755); err != nil { + return err + } + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + if entry.IsDir() { + if err := copyDir(srcPath, dstPath); err != nil { + return err + } + } else { + if err := copyFile(srcPath, dstPath); err != nil { + return err + } + } + } + return nil +} + +// copyFile copies a single file from src to dst. +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/pkg/replication/telepathy.go b/pkg/replication/telepathy.go new file mode 100644 index 0000000000..ee4a4ac0e0 --- /dev/null +++ b/pkg/replication/telepathy.go @@ -0,0 +1,158 @@ +package replication + +import ( + "fmt" + "sync" + "time" + + "github.com/GemachDAO/Gclaw/pkg/bus" +) + +// TelepathyMessage is a message exchanged between parent and child agents. +type TelepathyMessage struct { + FromAgentID string `json:"from"` + ToAgentID string `json:"to"` // "*" for broadcast to all family + Type string `json:"type"` // "trade_signal", "market_insight", "strategy_update", "warning", "goodwill_share" + Content string `json:"content"` + Timestamp int64 `json:"timestamp"` + Priority int `json:"priority"` // 0=low, 1=normal, 2=urgent +} + +// TradeSignal carries a structured trade recommendation. +type TradeSignal struct { + Action string `json:"action"` // "buy", "sell", "watch" + TokenAddress string `json:"token_address"` + ChainID int `json:"chain_id"` + Confidence float64 `json:"confidence"` // 0.0-1.0 + Reasoning string `json:"reasoning"` + PriceAtSignal float64 `json:"price_at_signal"` +} + +// TelepathyBus enables in-process communication between parent and child agents. +type TelepathyBus struct { + parentBus *bus.MessageBus + familyID string + agentID string + subscribers map[string]chan TelepathyMessage + history []TelepathyMessage + maxHistory int + mu sync.RWMutex +} + +// NewTelepathyBus creates a TelepathyBus tied to the given parent bus and family. +func NewTelepathyBus(parentBus *bus.MessageBus, familyID, agentID string) *TelepathyBus { + return &TelepathyBus{ + parentBus: parentBus, + familyID: familyID, + agentID: agentID, + subscribers: make(map[string]chan TelepathyMessage), + history: []TelepathyMessage{}, + maxHistory: 500, + } +} + +// Broadcast sends a TelepathyMessage to all subscribed family members. +func (tb *TelepathyBus) Broadcast(msg TelepathyMessage) { + if msg.Timestamp == 0 { + msg.Timestamp = time.Now().UnixMilli() + } + msg.ToAgentID = "*" + + tb.mu.Lock() + tb.addHistory(msg) + channels := make([]chan TelepathyMessage, 0, len(tb.subscribers)) + for _, ch := range tb.subscribers { + channels = append(channels, ch) + } + tb.mu.Unlock() + + for _, ch := range channels { + select { + case ch <- msg: + default: + } + } +} + +// SendTo delivers a TelepathyMessage to a specific agent subscriber. +func (tb *TelepathyBus) SendTo(targetID string, msg TelepathyMessage) { + if msg.Timestamp == 0 { + msg.Timestamp = time.Now().UnixMilli() + } + msg.ToAgentID = targetID + + tb.mu.Lock() + tb.addHistory(msg) + ch, ok := tb.subscribers[targetID] + tb.mu.Unlock() + + if ok { + select { + case ch <- msg: + default: + } + } +} + +// Subscribe registers agentID for receiving messages and returns a read channel. +func (tb *TelepathyBus) Subscribe(agentID string) <-chan TelepathyMessage { + tb.mu.Lock() + defer tb.mu.Unlock() + ch := make(chan TelepathyMessage, 64) + tb.subscribers[agentID] = ch + return ch +} + +// Unsubscribe removes an agent's subscription and closes its channel. +func (tb *TelepathyBus) Unsubscribe(agentID string) { + tb.mu.Lock() + defer tb.mu.Unlock() + if ch, ok := tb.subscribers[agentID]; ok { + close(ch) + delete(tb.subscribers, agentID) + } +} + +// BroadcastTradeSignal is a convenience method that wraps a TradeSignal into +// a TelepathyMessage and broadcasts it to all family members. +func (tb *TelepathyBus) BroadcastTradeSignal(signal TradeSignal) { + content := signal.Action + " " + signal.TokenAddress + + " confidence=" + formatFloat(signal.Confidence) + + " reason=" + signal.Reasoning + + tb.Broadcast(TelepathyMessage{ + FromAgentID: tb.agentID, + Type: "trade_signal", + Content: content, + Priority: 1, + }) +} + +// GetHistory returns the last `limit` messages from the history buffer. +func (tb *TelepathyBus) GetHistory(limit int) []TelepathyMessage { + tb.mu.RLock() + defer tb.mu.RUnlock() + if limit <= 0 || limit >= len(tb.history) { + out := make([]TelepathyMessage, len(tb.history)) + copy(out, tb.history) + return out + } + start := len(tb.history) - limit + out := make([]TelepathyMessage, limit) + copy(out, tb.history[start:]) + return out +} + +// addHistory appends a message to the history, evicting oldest when at capacity. +// Caller must hold tb.mu.Lock(). +func (tb *TelepathyBus) addHistory(msg TelepathyMessage) { + tb.history = append(tb.history, msg) + if len(tb.history) > tb.maxHistory { + tb.history = tb.history[len(tb.history)-tb.maxHistory:] + } +} + +// formatFloat formats a float64 for use in message content. +func formatFloat(f float64) string { + return fmt.Sprintf("%.2f", f) +} diff --git a/pkg/replication/telepathy_file.go b/pkg/replication/telepathy_file.go new file mode 100644 index 0000000000..50b73b6c6a --- /dev/null +++ b/pkg/replication/telepathy_file.go @@ -0,0 +1,120 @@ +package replication + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// WriteMessage writes a TelepathyMessage as a JSON file into the telepathy +// directory for the given family. Files are named {timestamp}-{from_agent_id}.json. +func WriteMessage(dir string, msg TelepathyMessage) error { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create telepathy dir: %w", err) + } + + if msg.Timestamp == 0 { + msg.Timestamp = time.Now().UnixMilli() + } + + data, err := json.Marshal(msg) + if err != nil { + return err + } + + name := fmt.Sprintf("%d-%s.json", msg.Timestamp, sanitizeID(msg.FromAgentID)) + path := filepath.Join(dir, name) + return os.WriteFile(path, data, 0o600) +} + +// StartFileWatcher polls the given directory for new JSON message files and +// calls callback for each new message. It stops when done is closed. +// pollInterval controls the check frequency; if zero, defaults to 2 seconds. +// msgMaxAge controls when old message files are deleted; if zero, defaults to 1 hour. +func StartFileWatcher(dir string, pollInterval time.Duration, msgMaxAge time.Duration, callback func(TelepathyMessage), done <-chan struct{}) { + if pollInterval <= 0 { + pollInterval = 2 * time.Second + } + if msgMaxAge <= 0 { + msgMaxAge = time.Hour + } + + seen := make(map[string]struct{}) + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ticker.C: + cleanOldMessages(dir, msgMaxAge) + entries, err := os.ReadDir(dir) + if err != nil { + continue + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + if _, ok := seen[entry.Name()]; ok { + continue + } + seen[entry.Name()] = struct{}{} + path := filepath.Join(dir, entry.Name()) + data, err := os.ReadFile(path) + if err != nil { + continue + } + var msg TelepathyMessage + if err := json.Unmarshal(data, &msg); err != nil { + continue + } + callback(msg) + } + } + } +} + +// TelepathyDir returns the standard telepathy directory for a family within a workspace. +func TelepathyDir(workspace, familyID string) string { + return filepath.Join(workspace, "replication", "telepathy", familyID) +} + +// cleanOldMessages removes message files older than maxAge from the directory. +func cleanOldMessages(dir string, maxAge time.Duration) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + cutoff := time.Now().Add(-maxAge) + for _, entry := range entries { + if entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(dir, entry.Name())) + } + } +} + +// sanitizeID replaces characters that are invalid in filenames with underscores. +func sanitizeID(id string) string { + var b strings.Builder + for _, r := range id { + if r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' || r == '"' || r == '<' || r == '>' || r == '|' { + b.WriteRune('_') + } else { + b.WriteRune(r) + } + } + return b.String() +} diff --git a/pkg/tools/gdex_recode.go b/pkg/tools/gdex_recode.go new file mode 100644 index 0000000000..8b96425b77 --- /dev/null +++ b/pkg/tools/gdex_recode.go @@ -0,0 +1,115 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/GemachDAO/Gclaw/pkg/recode" +) + +// RecodeTool allows the agent to modify its own configuration when it has +// earned sufficient goodwill (default threshold: 100). +type RecodeTool struct { + recoder *recode.Recoder + goodwillCheck func() int + threshold int +} + +// NewRecodeTool creates a RecodeTool wired to the given Recoder. +func NewRecodeTool(r *recode.Recoder, goodwillCheck func() int, threshold int) *RecodeTool { + return &RecodeTool{ + recoder: r, + goodwillCheck: goodwillCheck, + threshold: threshold, + } +} + +func (t *RecodeTool) Name() string { return "self_recode" } + +func (t *RecodeTool) Description() string { + return "Modify your own configuration. You can update your system prompt, add cron jobs, install skills, or adjust trading parameters. Requires goodwill ≥ 100." +} + +func (t *RecodeTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "action": map[string]any{ + "type": "string", + "description": "The modification action: modify_prompt | add_cron | install_skill | adjust_trading", + "enum": []string{"modify_prompt", "add_cron", "install_skill", "adjust_trading"}, + }, + "value": map[string]any{ + "type": "string", + "description": "The modification content. For add_cron use 'SCHEDULE|TASK'. For install_skill use 'SLUG|REGISTRY'. For adjust_trading use JSON.", + }, + }, + "required": []string{"action", "value"}, + } +} + +func (t *RecodeTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + if t.recoder == nil { + return ErrorResult("recoder not configured") + } + + // Check goodwill threshold + if t.goodwillCheck != nil { + gw := t.goodwillCheck() + if gw < t.threshold { + return ErrorResult(fmt.Sprintf( + "insufficient goodwill for self-recode: have %d, need %d", + gw, t.threshold, + )) + } + } + + action, _ := args["action"].(string) + value, _ := args["value"].(string) + if action == "" || value == "" { + return ErrorResult("action and value are required") + } + + var err error + switch action { + case "modify_prompt": + err = t.recoder.ModifySystemPrompt(value) + case "add_cron": + schedule, task := splitPipe(value) + if schedule == "" || task == "" { + return ErrorResult("for add_cron, value must be 'SCHEDULE|TASK'") + } + err = t.recoder.AddCronJob(schedule, task) + case "install_skill": + slug, registry := splitPipe(value) + if slug == "" { + return ErrorResult("for install_skill, value must be 'SLUG|REGISTRY'") + } + err = t.recoder.InstallSkill(slug, registry) + case "adjust_trading": + var params map[string]any + if jsonErr := json.Unmarshal([]byte(value), ¶ms); jsonErr != nil { + return ErrorResult(fmt.Sprintf("adjust_trading value must be JSON: %v", jsonErr)) + } + err = t.recoder.AdjustTradingParams(params) + default: + return ErrorResult(fmt.Sprintf("unknown action %q", action)) + } + + if err != nil { + return ErrorResult(fmt.Sprintf("self_recode failed: %v", err)) + } + + return SilentResult(fmt.Sprintf("self_recode action '%s' applied successfully", action)) +} + +// splitPipe splits "A|B" into ("A", "B"). Returns ("A", "") if no pipe. +func splitPipe(s string) (string, string) { + for i, c := range s { + if c == '|' { + return s[:i], s[i+1:] + } + } + return s, "" +} diff --git a/pkg/tools/gdex_replicate.go b/pkg/tools/gdex_replicate.go new file mode 100644 index 0000000000..76dd8d195c --- /dev/null +++ b/pkg/tools/gdex_replicate.go @@ -0,0 +1,97 @@ +package tools + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/GemachDAO/Gclaw/pkg/config" + "github.com/GemachDAO/Gclaw/pkg/replication" +) + +// ReplicateTool allows the agent to create a child agent via self-replication. +// Replication is goodwill-gated: the agent must reach the configured threshold. +type ReplicateTool struct { + replicator *replication.Replicator + parentConfig *config.Config + workspace string + parentGMAC *float64 + goodwillCheck func() int + threshold int +} + +// NewReplicateTool creates a ReplicateTool wired to the given Replicator. +// goodwillCheck returns the current goodwill score; threshold is the minimum required. +func NewReplicateTool( + r *replication.Replicator, + parentConfig *config.Config, + workspace string, + parentGMAC *float64, + goodwillCheck func() int, + threshold int, +) *ReplicateTool { + return &ReplicateTool{ + replicator: r, + parentConfig: parentConfig, + workspace: workspace, + parentGMAC: parentGMAC, + goodwillCheck: goodwillCheck, + threshold: threshold, + } +} + +func (t *ReplicateTool) Name() string { return "replicate" } + +func (t *ReplicateTool) Description() string { + return "Create a child agent that inherits your config, skills, and memory. Requires sufficient goodwill. The child gets a portion of your GMAC balance and a mutated trading strategy." +} + +func (t *ReplicateTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{ + "type": "string", + "description": "Optional label for the child agent", + }, + "strategy_hint": map[string]any{ + "type": "string", + "description": "Optional hint to influence the child's trading strategy mutation", + }, + }, + } +} + +func (t *ReplicateTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + if t.replicator == nil { + return ErrorResult("replicator not configured") + } + + // Check goodwill threshold + if t.goodwillCheck != nil { + gw := t.goodwillCheck() + if !t.replicator.CanReplicate(gw, t.threshold) { + return ErrorResult(fmt.Sprintf( + "insufficient goodwill for replication: have %d, need %d", + gw, t.threshold, + )) + } + } + + gmac := float64(0) + if t.parentGMAC != nil { + gmac = *t.parentGMAC + } + + child, err := t.replicator.Replicate(t.parentConfig, t.workspace, &gmac) + if err != nil { + return ErrorResult(fmt.Sprintf("replication failed: %v", err)) + } + + if t.parentGMAC != nil { + *t.parentGMAC = gmac + } + + out, _ := json.MarshalIndent(child, "", " ") + return SilentResult(fmt.Sprintf("Child agent created:\n%s", string(out))) +} diff --git a/pkg/tools/gdex_telepathy.go b/pkg/tools/gdex_telepathy.go new file mode 100644 index 0000000000..47e80c60c0 --- /dev/null +++ b/pkg/tools/gdex_telepathy.go @@ -0,0 +1,91 @@ +package tools + +import ( + "context" + "fmt" + "time" + + "github.com/GemachDAO/Gclaw/pkg/replication" +) + +// TelepathyTool allows the agent to send messages to its parent or child agents +// via the telepathy bus. +type TelepathyTool struct { + bus *replication.TelepathyBus + agentID string +} + +// NewTelepathyTool creates a TelepathyTool wired to the given TelepathyBus. +func NewTelepathyTool(bus *replication.TelepathyBus, agentID string) *TelepathyTool { + return &TelepathyTool{bus: bus, agentID: agentID} +} + +func (t *TelepathyTool) Name() string { return "telepathy" } + +func (t *TelepathyTool) Description() string { + return "Send a message to your parent or child agents via the telepathy bus. Share trade signals, market insights, or coordinate strategies with your agent family." +} + +func (t *TelepathyTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "to": map[string]any{ + "type": "string", + "description": "Target agent ID, or \"*\" for broadcast to all family members (default \"*\")", + }, + "type": map[string]any{ + "type": "string", + "description": "Message type: trade_signal | market_insight | strategy_update | warning", + "enum": []string{"trade_signal", "market_insight", "strategy_update", "warning"}, + }, + "content": map[string]any{ + "type": "string", + "description": "The message content", + }, + "priority": map[string]any{ + "type": "number", + "description": "Message priority: 0=low, 1=normal, 2=urgent (default 1)", + }, + }, + "required": []string{"type", "content"}, + } +} + +func (t *TelepathyTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + if t.bus == nil { + return ErrorResult("telepathy bus not configured") + } + + msgType, _ := args["type"].(string) + content, _ := args["content"].(string) + if msgType == "" || content == "" { + return ErrorResult("type and content are required") + } + + to, _ := args["to"].(string) + if to == "" { + to = "*" + } + + priority := 1 + if p, ok := args["priority"].(float64); ok { + priority = int(p) + } + + msg := replication.TelepathyMessage{ + FromAgentID: t.agentID, + ToAgentID: to, + Type: msgType, + Content: content, + Timestamp: time.Now().UnixMilli(), + Priority: priority, + } + + if to == "*" { + t.bus.Broadcast(msg) + return SilentResult(fmt.Sprintf("broadcast %s message to all family members", msgType)) + } + t.bus.SendTo(to, msg) + return SilentResult(fmt.Sprintf("sent %s message to agent %s", msgType, to)) +}