Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions config/config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,11 @@
"rebalance_interval": 60,
"shared_wallet_mode": false
},
"dashboard": {
"enabled": true,
"web_enabled": true,
"refresh_interval": 10
},
"gateway": {
"host": "127.0.0.1",
"port": 18790
Expand Down
41 changes: 41 additions & 0 deletions pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/GemachDAO/Gclaw/pkg/channels"
"github.com/GemachDAO/Gclaw/pkg/config"
"github.com/GemachDAO/Gclaw/pkg/constants"
"github.com/GemachDAO/Gclaw/pkg/dashboard"
"github.com/GemachDAO/Gclaw/pkg/logger"
"github.com/GemachDAO/Gclaw/pkg/metabolism"
"github.com/GemachDAO/Gclaw/pkg/providers"
Expand Down Expand Up @@ -211,6 +212,46 @@ func registerSharedTools(
}
}

// Dashboard tool β€” always registered when dashboard is enabled
if cfg.Dashboard.Enabled {
startedAt := time.Now().UnixMilli()
currentAgentIDForDash := agentID
agentMet := agent.Tools.GetMetabolism()
dash := dashboard.NewDashboard(dashboard.DashboardOptions{
AgentID: currentAgentIDForDash,
StartedAt: startedAt,
GetMetabolism: func() *dashboard.MetabolismSnapshot {
if agentMet == nil {
return nil
}
status := agentMet.GetStatus()
ledger := agentMet.GetLedger()
recent := ledger
if len(recent) > 20 {
recent = recent[len(recent)-20:]
}
entries := make([]dashboard.LedgerEntry, len(recent))
for i, e := range recent {
entries[i] = dashboard.LedgerEntry{
Timestamp: e.Timestamp,
Action: e.Action,
Amount: e.Amount,
Balance: e.Balance,
Details: e.Details,
}
}
return &dashboard.MetabolismSnapshot{
Balance: status.Balance,
Goodwill: status.Goodwill,
SurvivalMode: status.SurvivalMode,
Abilities: status.Abilities,
RecentLedger: entries,
}
},
})
agent.Tools.Register(tools.NewDashboardTool(dash))
}

// Update context builder with the complete tools registry
agent.ContextBuilder.SetToolsRegistry(agent.Tools)
}
Expand Down
8 changes: 8 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ type Config struct {
Devices DevicesConfig `json:"devices"`
Metabolism MetabolismConfig `json:"metabolism"`
Swarm SwarmConfig `json:"swarm"`
Dashboard DashboardConfig `json:"dashboard"`
}

// MarshalJSON implements custom JSON marshaling for Config
Expand Down Expand Up @@ -503,6 +504,13 @@ type SwarmConfig struct {
SharedWalletMode bool `json:"shared_wallet_mode"` // all agents trade from same wallet (default false)
}

// DashboardConfig holds configuration for the living agent dashboard.
type DashboardConfig struct {
Enabled bool `json:"enabled" env:"GCLAW_DASHBOARD_ENABLED"`
WebEnabled bool `json:"web_enabled" env:"GCLAW_DASHBOARD_WEB_ENABLED"`
RefreshInterval int `json:"refresh_interval" env:"GCLAW_DASHBOARD_REFRESH_INTERVAL"` // seconds, default 10
}

// GDEXConfig holds configuration for GDEX DeFi trading tools.
// DefaultChainID, MaxTradeSizeSOL, and AutoTrade are provided for future
// enforcement by the agent; current tool implementations delegate to the
Expand Down
190 changes: 190 additions & 0 deletions pkg/dashboard/cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Gclaw - Ultra-lightweight personal AI agent
// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot
// License: MIT
//
// Copyright (c) 2026 Gclaw contributors

package dashboard

import (
"fmt"
"strings"
"time"
)

const dashboardWidth = 56

// FormatCLI renders DashboardData as a formatted CLI string.
func FormatCLI(data *DashboardData) string {
if data == nil {
return ""
}

var sb strings.Builder
border := strings.Repeat("═", dashboardWidth)

line := func(content string) {
padded := fmt.Sprintf("%-*s", dashboardWidth, content)
sb.WriteString("β•‘ " + padded + " β•‘\n")
}
divider := func() {
sb.WriteString("β• " + border + "β•£\n")
}

sb.WriteString("β•”" + border + "β•—\n")
line(centerText("🦞 GCLAW LIVING AGENT", dashboardWidth))
divider()

// Identity row
gen := data.Generation
line(fmt.Sprintf("Agent: %-16sβ”‚ Gen: %-2d β”‚ Uptime: %s",
truncate(data.AgentID, 16), gen, data.Uptime))
divider()

// Metabolism
line("πŸ’° METABOLISM")
if data.Metabolism != nil {
m := data.Metabolism
mode := "ACTIVE"
if m.SurvivalMode {
mode = "SURVIVAL"
}
line(fmt.Sprintf(" Balance: %-10.2f GMAC Goodwill: %d",
m.Balance, m.Goodwill))
abilities := strings.Join(m.Abilities, ", ")
if abilities == "" {
abilities = "none"
}
line(fmt.Sprintf(" Mode: %-16s Abilities: %s", mode, truncate(abilities, 24)))
} else {
line(" (not configured)")
}
divider()

// Trading
line("πŸ“Š TRADING")
if data.Trading != nil {
t := data.Trading
pnlSign := "+"
if t.TotalPnL < 0 {
pnlSign = ""
}
line(fmt.Sprintf(" Trades: %-4d Win Rate: %.1f%% P&L: %s%.2f GMAC",
t.TotalTrades, t.ProfitablePct, pnlSign, t.TotalPnL))
} else {
line(" (not configured)")
}
divider()

// Family tree
line("πŸ‘¨β€πŸ‘§β€πŸ‘¦ FAMILY TREE")
if data.Family != nil && len(data.Family.Children) > 0 {
line(fmt.Sprintf(" Children: %d", len(data.Family.Children)))
for i, child := range data.Family.Children {
prefix := " β”œβ”€"
if i == len(data.Family.Children)-1 {
prefix = " └─"
}
line(fmt.Sprintf("%s %s [%s] %s",
prefix,
truncate(child.ID, 18),
child.Status,
truncate(strings.Join(child.Mutations, ","), 12)))
}
} else {
line(" No children")
}
divider()

// Telepathy
line("πŸ“‘ TELEPATHY")
if data.Telepathy != nil {
tp := data.Telepathy
line(fmt.Sprintf(" Messages: %-4d Active Channels: %d",
tp.TotalMessages, tp.ActiveChannels))
if len(tp.RecentMessages) > 0 {
last := tp.RecentMessages[len(tp.RecentMessages)-1]
ago := formatAgo(last.Timestamp)
line(fmt.Sprintf(" Latest: %s from %s (%s)",
truncate(last.Type, 16),
truncate(last.From, 12),
ago))
}
} else {
line(" (not configured)")
}
divider()

// Swarm
line("🐝 SWARM")
if data.Swarm != nil {
sw := data.Swarm
role := "member"
if sw.IsLeader {
role = "Leader"
}
line(fmt.Sprintf(" Role: %-8s Members: %-3d Consensus: %s",
role, sw.MemberCount, truncate(sw.ConsensusMode, 10)))
} else {
line(" (not configured)")
}
divider()

// System
line("βš™οΈ SYSTEM")
if data.System != nil {
sys := data.System
hb := "❌"
if sys.HeartbeatActive {
hb = "βœ…"
}
line(fmt.Sprintf(" Heartbeat: %s (%dmin) Tools: %-4d Skills: %d",
hb, sys.HeartbeatInterval, sys.ToolCount, sys.SkillCount))
} else {
line(" (not configured)")
}

sb.WriteString("β•š" + border + "╝\n")
return sb.String()
}

// centerText centers a string within a fixed width (best-effort, ignoring multi-byte rune widths).
func centerText(s string, width int) string {
if len(s) >= width {
return s
}
pad := (width - len(s)) / 2
return strings.Repeat(" ", pad) + s
}

// truncate shortens s to at most n runes, appending "…" if truncated.
func truncate(s string, n int) string {
runes := []rune(s)
if len(runes) <= n {
return s
}
if n <= 1 {
return string(runes[:n])
}
return string(runes[:n-1]) + "…"
}

// formatAgo returns a short human-readable "time ago" string for a Unix ms timestamp.
func formatAgo(ms int64) string {
if ms == 0 {
return "unknown"
}
diff := time.Now().UnixMilli() - ms
if diff < 0 {
diff = 0
}
secs := diff / 1000
if secs < 60 {
return fmt.Sprintf("%ds ago", secs)
}
mins := secs / 60
if mins < 60 {
return fmt.Sprintf("%dm ago", mins)
}
return fmt.Sprintf("%dh ago", mins/60)
}
Loading