This commit is contained in:
Dark aura 2026-05-07 05:52:53 +01:00
commit d0cac40dd3
22 changed files with 1795 additions and 19 deletions

2
.gitignore vendored
View file

@ -11,6 +11,8 @@ build/
/picoclaw
/picoclaw-test
cmd/**/workspace
.gocache/
dist/
# Picoclaw specific

49
AGENTS.md Normal file
View file

@ -0,0 +1,49 @@
Microsoft Windows [Version 10.0.26200.8116]
(c) Microsoft Corporation. All rights reserved.
C:\Users\user>ollama list
NAME ID SIZE MODIFIED
smollm2:1.7b cef4a1e09247 1.8 GB 5 hours ago
qwen3.5:4b 2a654d98e6fb 3.4 GB 3 days ago
deepseek-v4-pro:cloud 22bfd5026abd - 3 days ago
qwen3-coder-next:cloud aa626c11ae8d - 2 months ago
C:\Users\user>
# PicoClaw
## Build & Test Commands
```bash
make build # Build for current platform (runs generate first)
make build-all # Cross-compile for all supported platforms
make test # Run Go tests + web tests
make lint # golangci-lint with goolm,stdjson tags
make check # deps + fmt + vet + test + lint-docs
```
## Environment Setup
- Copy `.env.example` to `.env` and configure API keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, etc.)
- Go 1.25.9+ required
- Build tags: `goolm,stdjson` (set via GO_BUILD_TAGS or Makefile)
- CGO_ENABLED=0 by default; CGO_ENABLED=1 only for macOS launcher builds
## Critical Constraints
- **Always run `make generate` before `make build`** — code generation creates required workspace symlinks
- **Never edit `cmd/picoclaw/workspace/` directly** — it's regenerated by `go generate`
- **MIPS builds require ELF e_flags patch** — handled automatically by Makefile
- **loong64 needs manual ztypes_loong64.go** — handled automatically by Makefile
- **WhatsApp native builds** add `whatsapp_native` tag but produce larger binaries
- **Workspace location**: `~/.picoclaw/workspace` (skills, memory stored here at runtime)
- **macOS launcher**: requires CGO_ENABLED=1 and minimal macOS 10.11 target
- **No hardcoded API keys in CLI** — project is migrating to OAuth 2.0 flows
- **Memory target**: core process <20MB for 64MB RAM boards; optimize data structures over storage
## Architecture Notes
- **Protocol-first**: Migrating from vendor-based to protocol-based provider classification (OpenAI-compat, Ollama-compat)
- **Multi-architecture**: x86_64, ARM64, MIPS, RISC-V, LoongArch
- **14+ chat channels** via adapter pattern in pkg/channels/
- **MCP support**: Model Context Protocol server in pkg/mcp/
- **Tools API**: See `docs/reference/tools-api.md` for complete tools documentation

View file

@ -1,3 +1,15 @@
Microsoft Windows [Version 10.0.26200.8116]
(c) Microsoft Corporation. All rights reserved.
C:\Users\user>ollama list
NAME ID SIZE MODIFIED
smollm2:1.7b cef4a1e09247 1.8 GB 5 hours ago
qwen3.5:4b 2a654d98e6fb 3.4 GB 3 days ago
deepseek-v4-pro:cloud 22bfd5026abd - 3 days ago
qwen3-coder-next:cloud aa626c11ae8d - 2 months ago
C:\Users\user>
# PicoClaw
## Build & Test Commands

View file

@ -3,7 +3,7 @@
"defaults": {
"workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model_name": "gpt-5.4",
"model_name": "smollm2",
"max_tokens": 8192,
"context_window": 131072,
"temperature": 0.7,
@ -21,6 +21,24 @@
}
},
"model_list": [
{
"model_name": "smollm2",
"provider": "ollama",
"model": "smollm2:1.7b",
"api_base": "http://localhost:11434/v1"
},
{
"model_name": "qwen3.5",
"provider": "ollama",
"model": "qwen3.5:4b",
"api_base": "http://localhost:11434/v1"
},
{
"model_name": "deepseek-v4-pro",
"provider": "ollama",
"model": "deepseek-v4-pro:cloud",
"api_base": "http://localhost:11434/v1"
},
{
"model_name": "gpt-5.4",
"model": "openai/gpt-5.4",

View file

@ -28,6 +28,7 @@ import (
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/session"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -57,6 +58,7 @@ type AgentLoop struct {
mcp mcpRuntime
hookRuntime hookRuntime
steering *steeringQueue
subagents *tools.SubagentManager
pendingSkills sync.Map
mu sync.RWMutex

View file

@ -242,6 +242,9 @@ func registerSharedTools(
if (spawnEnabled || spawnStatusEnabled) && cfg.Tools.IsToolEnabled("subagent") {
subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace)
subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature)
if agentID == "main" {
al.subagents = subagentManager
}
// Inject a media resolver so the legacy RunToolLoop fallback path can
// resolve media:// refs in the same way the main AgentLoop does.

View file

@ -119,3 +119,29 @@ func (al *AgentLoop) GetStartupInfo() map[string]any {
return info
}
func (al *AgentLoop) GetMainSubagentTasks(channel, chatID string) []tools.SubagentTask {
al.mu.RLock()
manager := al.subagents
al.mu.RUnlock()
if manager == nil {
return nil
}
all := manager.ListTaskCopies()
if channel == "" && chatID == "" {
return all
}
filtered := make([]tools.SubagentTask, 0, len(all))
for _, task := range all {
if channel != "" && task.OriginChannel != "" && task.OriginChannel != channel {
continue
}
if chatID != "" && task.OriginChatID != "" && task.OriginChatID != chatID {
continue
}
filtered = append(filtered, task)
}
return filtered
}

View file

@ -246,6 +246,13 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) (runEr
runningServices.HealthServer.SetPermissionGrantFunc(func(agentID, path, duration string) error {
return agentLoop.GrantPermission(agentID, path, duration)
})
runningServices.HealthServer.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
return map[string]any{
"channel": channel,
"chat_id": chatID,
"tasks": agentLoop.GetMainSubagentTasks(channel, chatID),
}, nil
})
for _, bindHost := range listenResult.BindHosts {
fmt.Printf("✓ Gateway started on %s\n", net.JoinHostPort(bindHost, strconv.Itoa(cfg.Gateway.Port)))

View file

@ -19,8 +19,9 @@ type Server struct {
ready bool
checks map[string]Check
startTime time.Time
reloadFunc func() error
reloadFunc func() error
permissionGrantFunc func(agentID, path, duration string) error
subagentStatusFunc func(channel, chatID string) (any, error)
authToken string // optional bearer token for protected endpoints
}
@ -51,6 +52,7 @@ func NewServer(host string, port int, token string) *Server {
mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler)
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
mux.HandleFunc("/internal/subagents/status", s.subagentStatusHandler)
addr := net.JoinHostPort(host, strconv.Itoa(port))
s.server = &http.Server{
@ -128,6 +130,12 @@ func (s *Server) SetPermissionGrantFunc(fn func(agentID, path, duration string)
s.permissionGrantFunc = fn
}
func (s *Server) SetSubagentStatusFunc(fn func(channel, chatID string) (any, error)) {
s.mu.Lock()
defer s.mu.Unlock()
s.subagentStatusFunc = fn
}
// permissionGrantHandler handles POST /internal/permission/grant requests.
func (s *Server) permissionGrantHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@ -241,6 +249,49 @@ func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{"status": "reload triggered"})
}
func (s *Server) subagentStatusHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed)
json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed, use GET"})
return
}
s.mu.RLock()
requiredToken := s.authToken
statusFunc := s.subagentStatusFunc
s.mu.RUnlock()
if requiredToken != "" {
given := extractBearerToken(r.Header.Get("Authorization"))
if given == "" || subtle.ConstantTimeCompare([]byte(given), []byte(requiredToken)) != 1 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"})
return
}
}
if statusFunc == nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"error": "subagent status not configured"})
return
}
payload, err := statusFunc(r.URL.Query().Get("channel"), r.URL.Query().Get("chat_id"))
if err != nil {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(payload)
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
@ -307,6 +358,8 @@ func (s *Server) RegisterOnMux(mux HandlerMux) {
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
mux.HandleFunc("/reload", s.reloadHandler)
mux.HandleFunc("/internal/permission/grant", s.permissionGrantHandler)
mux.HandleFunc("/internal/subagents/status", s.subagentStatusHandler)
}
func statusString(ok bool) string {

View file

@ -215,6 +215,60 @@ func TestReloadHandler_Error(t *testing.T) {
}
}
func TestSubagentStatusHandler_Success(t *testing.T) {
s := newTestServer()
s.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
return map[string]any{
"channel": channel,
"chat_id": chatID,
"tasks": []map[string]any{
{"id": "subagent-1", "status": "running"},
},
}, nil
})
req := httptest.NewRequest(http.MethodGet, "/internal/subagents/status?channel=pico&chat_id=session-1", nil)
req.Header.Set("Authorization", "Bearer test")
w := httptest.NewRecorder()
s.subagentStatusHandler(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", w.Code, http.StatusOK, w.Body.String())
}
var resp struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Tasks []map[string]any `json:"tasks"`
}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.Channel != "pico" || resp.ChatID != "session-1" {
t.Fatalf("response = %#v, want channel/chat_id preserved", resp)
}
if len(resp.Tasks) != 1 {
t.Fatalf("tasks len = %d, want 1", len(resp.Tasks))
}
}
func TestSubagentStatusHandler_RequiresAuth(t *testing.T) {
s := newTestServer()
s.SetSubagentStatusFunc(func(channel, chatID string) (any, error) {
return map[string]any{}, nil
})
req := httptest.NewRequest(http.MethodGet, "/internal/subagents/status", nil)
w := httptest.NewRecorder()
s.subagentStatusHandler(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", w.Code, http.StatusUnauthorized)
}
}
func TestSetReady_Toggle(t *testing.T) {
s := newTestServer()

View file

@ -0,0 +1,98 @@
@echo off
REM Build PicoClaw core and web launcher without using make.
REM Usage: scripts\build-without-make.bat
SETLOCAL ENABLEEXTENSIONS
SET "GO_TAGS=goolm,stdjson"
REM Resolve the repository root directory from the script location.
PUSHD "%~dp0.."
IF ERRORLEVEL 1 (
echo ERROR: Failed to resolve repository root from "%~dp0..".
EXIT /B 1
)
SET "REPO_ROOT=%CD%"
POPD
REM Ensure Go is available
where go >nul 2>&1
IF ERRORLEVEL 1 (
echo ERROR: go is not installed or not on PATH.
EXIT /B 1
)
REM Ensure pnpm is available
where pnpm >nul 2>&1
IF ERRORLEVEL 1 (
echo ERROR: pnpm is not installed or not on PATH.
EXIT /B 1
)
PUSHD "%REPO_ROOT%"
IF ERRORLEVEL 1 (
echo ERROR: Failed to change directory to "%REPO_ROOT%".
EXIT /B 1
)
IF NOT EXIST build (
mkdir build
)
echo === Generating Go code ===
go generate ./...
IF ERRORLEVEL 1 (
echo ERROR: go generate failed.
POPD
EXIT /B 1
)
echo.
echo === Building PicoClaw core binary ===
go build -tags "%GO_TAGS%" -o build\picoclaw.exe .\cmd\picoclaw
IF ERRORLEVEL 1 (
echo ERROR: go build failed for core binary.
POPD
EXIT /B 1
)
echo.
echo === Building PicoClaw web frontend assets ===
PUSHD "%REPO_ROOT%\web\frontend"
IF ERRORLEVEL 1 (
echo ERROR: Failed to change directory to web\frontend.
POPD
EXIT /B 1
)
CALL pnpm install --frozen-lockfile
IF ERRORLEVEL 1 (
echo ERROR: pnpm install failed.
POPD
POPD
EXIT /B 1
)
CALL pnpm build:backend
IF ERRORLEVEL 1 (
echo ERROR: pnpm build:backend failed.
POPD
POPD
EXIT /B 1
)
POPd
echo.
echo === Building PicoClaw web launcher ===
go build -tags "%GO_TAGS%" -o build\picoclaw-launcher.exe .\web\backend
IF ERRORLEVEL 1 (
echo ERROR: go build failed for web launcher.
POPD
EXIT /B 1
)
echo.
echo === Build complete ===
echo Core binary: %REPO_ROOT%\build\picoclaw.exe
echo Web launcher: %REPO_ROOT%\build\picoclaw-launcher.exe
POPD
EXIT /B 0

View file

@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Build PicoClaw core and web launcher without using make.
# Usage: ./scripts/build-without-make.sh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd)"
cd "${REPO_ROOT}"
GO_TAGS="goolm,stdjson"
EXE_EXT=""
UNAME_S="$(uname -s 2>/dev/null || echo unknown)"
case "${UNAME_S}" in
*MINGW*|*MSYS*|*CYGWIN*|*Windows_NT*)
EXE_EXT=".exe"
;;
esac
command -v go >/dev/null 2>&1 || {
echo "ERROR: go is not installed or not on PATH."
exit 1
}
command -v pnpm >/dev/null 2>&1 || {
echo "ERROR: pnpm is not installed or not on PATH."
exit 1
}
mkdir -p "${REPO_ROOT}/build"
echo "=== Generating Go code ==="
go generate ./...
echo ""
echo "=== Building PicoClaw core binary ==="
go build -tags "${GO_TAGS}" -o "${REPO_ROOT}/build/picoclaw${EXE_EXT}" ./cmd/picoclaw
echo ""
echo "=== Building PicoClaw web frontend assets ==="
cd "${REPO_ROOT}/web/frontend"
pnpm install --frozen-lockfile
pnpm build:backend
echo ""
echo "=== Building PicoClaw web launcher ==="
cd "${REPO_ROOT}"
go build -tags "${GO_TAGS}" -o "${REPO_ROOT}/build/picoclaw-launcher${EXE_EXT}" ./web/backend
echo ""
echo "=== Build complete ==="
echo "Core binary: ${REPO_ROOT}/build/picoclaw${EXE_EXT}"
echo "Web launcher: ${REPO_ROOT}/build/picoclaw-launcher${EXE_EXT}"

View file

@ -5,8 +5,14 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httputil"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
@ -17,6 +23,8 @@ import (
// registerPicoRoutes binds Pico Channel management endpoints to the ServeMux.
func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/pico/info", h.handleGetPicoInfo)
mux.HandleFunc("GET /api/pico/memory-graph", h.handleGetPicoMemoryGraph)
mux.HandleFunc("GET /api/pico/subagents", h.handleGetPicoSubagents)
mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken)
mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup)
@ -28,6 +36,43 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) {
mux.HandleFunc("HEAD /pico/media/{id}", h.handlePicoMediaProxy())
}
type picoSubagentStatusItem struct {
ID string `json:"id"`
Label string `json:"label,omitempty"`
Status string `json:"status"`
Created int64 `json:"created"`
Result string `json:"result,omitempty"`
}
type picoSubagentStatusResponse struct {
SessionID string `json:"session_id"`
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Tasks []picoSubagentStatusItem `json:"tasks"`
}
type picoMemoryGraphNode struct {
ID string `json:"id"`
Label string `json:"label"`
Kind string `json:"kind"`
Group string `json:"group"`
Preview string `json:"preview,omitempty"`
Weight int `json:"weight,omitempty"`
}
type picoMemoryGraphEdge struct {
Source string `json:"source"`
Target string `json:"target"`
Kind string `json:"kind"`
}
type picoMemoryGraphResponse struct {
SessionID string `json:"session_id"`
GeneratedAt string `json:"generated_at"`
Nodes []picoMemoryGraphNode `json:"nodes"`
Edges []picoMemoryGraphEdge `json:"edges"`
}
// createWsProxy creates a reverse proxy to the current gateway WebSocket endpoint.
// The gateway bind host and port are resolved from the latest configuration.
func (h *Handler) createWsProxy(origProtocol string, upstreamProtocol string) *httputil.ReverseProxy {
@ -207,6 +252,433 @@ func (h *Handler) handleGetPicoInfo(w http.ResponseWriter, r *http.Request) {
h.writePicoInfoResponse(w, r, cfg, nil)
}
func (h *Handler) handleGetPicoMemoryGraph(w http.ResponseWriter, r *http.Request) {
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
http.Error(w, "session_id is required", http.StatusBadRequest)
return
}
cfg, err := config.LoadConfig(h.configPath)
if err != nil {
http.Error(w, "failed to load config", http.StatusInternalServerError)
return
}
sessionDir := resolveSessionsDir(cfg.Agents.Defaults.Workspace)
toolFeedbackMaxArgsLength := cfg.Agents.Defaults.GetToolFeedbackMaxArgsLength()
workspaceDir := resolveWorkspaceDir(cfg.Agents.Defaults.Workspace)
ref, refErr := h.findPicoJSONLSession(sessionDir, sessionID)
var sess sessionFile
err = refErr
if refErr == nil {
sess, err = h.readJSONLSession(sessionDir, ref.Key)
}
if err != nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}
nodes, edges := buildPicoMemoryGraph(
sessionID,
detailSessionMessages(sess.Messages, toolFeedbackMaxArgsLength),
workspaceDir,
)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(picoMemoryGraphResponse{
SessionID: sessionID,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
Nodes: nodes,
Edges: edges,
})
}
func (h *Handler) handleGetPicoSubagents(w http.ResponseWriter, r *http.Request) {
sessionID := strings.TrimSpace(r.URL.Query().Get("session_id"))
if sessionID == "" {
http.Error(w, "session_id is required", http.StatusBadRequest)
return
}
if !h.gatewayAvailableForProxy() {
logger.Warnf("Gateway not available for Pico subagent status proxy")
http.Error(w, "Gateway not available", http.StatusServiceUnavailable)
return
}
gateway.mu.Lock()
pidData := gateway.pidData
gateway.mu.Unlock()
if pidData == nil || pidData.Token == "" {
logger.Warnf("Gateway auth token not available for Pico subagent status proxy")
http.Error(w, "Gateway auth token not available", http.StatusServiceUnavailable)
return
}
target := h.gatewayProxyURL()
target.Path = "/internal/subagents/status"
query := target.Query()
query.Set("channel", "pico")
query.Set("chat_id", sessionID)
target.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target.String(), nil)
if err != nil {
http.Error(w, "Failed to create subagent status request", http.StatusInternalServerError)
return
}
req.Header.Set("Authorization", "Bearer "+pidData.Token)
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
if err != nil {
logger.Errorf("Failed to fetch Pico subagent status: %v", err)
http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
return
}
var upstream struct {
Channel string `json:"channel"`
ChatID string `json:"chat_id"`
Tasks []struct {
ID string `json:"id"`
Label string `json:"label"`
Status string `json:"status"`
Created int64 `json:"created"`
Result string `json:"result"`
} `json:"tasks"`
}
if err := json.NewDecoder(resp.Body).Decode(&upstream); err != nil {
http.Error(w, "Failed to decode subagent status response", http.StatusBadGateway)
return
}
tasks := make([]picoSubagentStatusItem, 0, len(upstream.Tasks))
for _, task := range upstream.Tasks {
tasks = append(tasks, picoSubagentStatusItem{
ID: task.ID,
Label: task.Label,
Status: task.Status,
Created: task.Created,
Result: summarizeSubagentResult(task.Result),
})
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(picoSubagentStatusResponse{
SessionID: sessionID,
Channel: upstream.Channel,
ChatID: upstream.ChatID,
Tasks: tasks,
})
}
func summarizeSubagentResult(result string) string {
result = strings.TrimSpace(result)
if result == "" {
return ""
}
const maxRunes = 180
runes := []rune(result)
if len(runes) <= maxRunes {
return result
}
return string(runes[:maxRunes]) + "..."
}
func resolveWorkspaceDir(workspace string) string {
if workspace == "" {
home, _ := os.UserHomeDir()
workspace = filepath.Join(home, ".picoclaw", "workspace")
}
if len(workspace) > 0 && workspace[0] == '~' {
home, _ := os.UserHomeDir()
if len(workspace) > 1 && workspace[1] == '/' {
workspace = home + workspace[1:]
} else {
workspace = home
}
}
return workspace
}
func buildPicoMemoryGraph(sessionID string, messages []sessionChatMessage, workspaceDir string) ([]picoMemoryGraphNode, []picoMemoryGraphEdge) {
nodes := make([]picoMemoryGraphNode, 0, 32)
edges := make([]picoMemoryGraphEdge, 0, 48)
seenNodes := make(map[string]struct{})
seenEdges := make(map[string]struct{})
addNode := func(node picoMemoryGraphNode) {
if _, exists := seenNodes[node.ID]; exists {
return
}
seenNodes[node.ID] = struct{}{}
nodes = append(nodes, node)
}
addEdge := func(edge picoMemoryGraphEdge) {
key := edge.Source + "\x00" + edge.Target + "\x00" + edge.Kind
if _, exists := seenEdges[key]; exists {
return
}
seenEdges[key] = struct{}{}
edges = append(edges, edge)
}
const (
memoryRootID = "memory-root"
sessionRootID = "session-root"
)
addNode(picoMemoryGraphNode{
ID: memoryRootID,
Label: "Workspace Memory",
Kind: "root",
Group: "memory",
Preview: "Long-term memory and recent notes",
Weight: 5,
})
addNode(picoMemoryGraphNode{
ID: sessionRootID,
Label: "Active Session",
Kind: "root",
Group: "session",
Preview: sessionID,
Weight: 5,
})
addEdge(picoMemoryGraphEdge{Source: memoryRootID, Target: sessionRootID, Kind: "context"})
appendMemoryDocumentGraph(memoryRootID, filepath.Join(workspaceDir, "memory", "MEMORY.md"), "memory", "Long-term Memory", 8, addNode, addEdge)
appendRecentDailyNoteGraph(memoryRootID, filepath.Join(workspaceDir, "memory"), addNode, addEdge)
appendSessionGraph(sessionRootID, messages, addNode, addEdge)
sort.Slice(nodes, func(i, j int) bool {
return nodes[i].ID < nodes[j].ID
})
sort.Slice(edges, func(i, j int) bool {
if edges[i].Source == edges[j].Source {
if edges[i].Target == edges[j].Target {
return edges[i].Kind < edges[j].Kind
}
return edges[i].Target < edges[j].Target
}
return edges[i].Source < edges[j].Source
})
return nodes, edges
}
func appendMemoryDocumentGraph(
rootID string,
path string,
group string,
title string,
maxItems int,
addNode func(picoMemoryGraphNode),
addEdge func(picoMemoryGraphEdge),
) {
data, err := os.ReadFile(path)
if err != nil || len(data) == 0 {
return
}
docID := group + ":document"
addNode(picoMemoryGraphNode{
ID: docID,
Label: title,
Kind: "document",
Group: group,
Preview: filepath.Base(path),
Weight: 4,
})
addEdge(picoMemoryGraphEdge{Source: rootID, Target: docID, Kind: "contains"})
lines := strings.Split(string(data), "\n")
currentParent := docID
items := 0
headingIndex := 0
noteIndex := 0
for _, rawLine := range lines {
line := strings.TrimSpace(rawLine)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") {
headingText := summarizeGraphText(strings.TrimSpace(strings.TrimLeft(line, "#")), 48)
if headingText == "" {
continue
}
headingIndex++
headingID := group + ":heading:" + strconv.Itoa(headingIndex)
addNode(picoMemoryGraphNode{
ID: headingID,
Label: headingText,
Kind: "heading",
Group: group,
Preview: headingText,
Weight: 3,
})
addEdge(picoMemoryGraphEdge{Source: docID, Target: headingID, Kind: "section"})
currentParent = headingID
continue
}
trimmed := strings.TrimSpace(strings.TrimLeft(line, "-*0123456789. "))
if trimmed == "" {
continue
}
noteIndex++
noteID := group + ":note:" + strconv.Itoa(noteIndex)
addNode(picoMemoryGraphNode{
ID: noteID,
Label: summarizeGraphText(trimmed, 38),
Kind: "note",
Group: group,
Preview: summarizeGraphText(trimmed, 140),
Weight: 2,
})
addEdge(picoMemoryGraphEdge{Source: currentParent, Target: noteID, Kind: "note"})
items++
if items >= maxItems {
break
}
}
}
func appendRecentDailyNoteGraph(
rootID string,
memoryDir string,
addNode func(picoMemoryGraphNode),
addEdge func(picoMemoryGraphEdge),
) {
matches, err := filepath.Glob(filepath.Join(memoryDir, "*", "*.md"))
if err != nil || len(matches) == 0 {
return
}
sort.Slice(matches, func(i, j int) bool {
return matches[i] > matches[j]
})
for idx, match := range matches {
if idx >= 3 {
break
}
appendMemoryDocumentGraph(
rootID,
match,
"daily-"+strconv.Itoa(idx+1),
"Daily Note "+filepath.Base(match),
4,
addNode,
addEdge,
)
}
}
func appendSessionGraph(
rootID string,
messages []sessionChatMessage,
addNode func(picoMemoryGraphNode),
addEdge func(picoMemoryGraphEdge),
) {
if len(messages) == 0 {
return
}
start := 0
if len(messages) > 8 {
start = len(messages) - 8
}
recent := messages[start:]
previousID := rootID
for index, message := range recent {
messageID := "session:message:" + strconv.Itoa(index)
label := strings.ToUpper(message.Role)
if preview := summarizeGraphText(message.Content, 34); preview != "" {
label += ": " + preview
}
if label == strings.ToUpper(message.Role) && len(message.Attachments) > 0 {
label += ": attachment"
}
addNode(picoMemoryGraphNode{
ID: messageID,
Label: label,
Kind: "message",
Group: "session",
Preview: summarizeGraphText(message.Content, 160),
Weight: 3,
})
addEdge(picoMemoryGraphEdge{Source: previousID, Target: messageID, Kind: "flow"})
previousID = messageID
for toolIndex, toolCall := range message.ToolCalls {
if toolCall.Function == nil {
continue
}
name := strings.TrimSpace(toolCall.Function.Name)
if name == "" {
continue
}
toolID := messageID + ":tool:" + strconv.Itoa(toolIndex)
addNode(picoMemoryGraphNode{
ID: toolID,
Label: name,
Kind: "tool",
Group: "tool",
Preview: summarizeGraphText(toolCall.Function.Arguments, 120),
Weight: 2,
})
addEdge(picoMemoryGraphEdge{Source: messageID, Target: toolID, Kind: "tool"})
}
for attachmentIndex, attachment := range message.Attachments {
name := strings.TrimSpace(attachment.Filename)
if name == "" {
name = attachment.Type
}
if name == "" {
name = "attachment"
}
attachmentID := messageID + ":attachment:" + strconv.Itoa(attachmentIndex)
addNode(picoMemoryGraphNode{
ID: attachmentID,
Label: summarizeGraphText(name, 34),
Kind: "attachment",
Group: "media",
Preview: summarizeGraphText(attachment.URL, 120),
Weight: 1,
})
addEdge(picoMemoryGraphEdge{Source: messageID, Target: attachmentID, Kind: "attachment"})
}
}
}
func summarizeGraphText(text string, maxRunes int) string {
trimmed := strings.Join(strings.Fields(strings.TrimSpace(text)), " ")
if trimmed == "" {
return ""
}
runes := []rune(trimmed)
if len(runes) <= maxRunes {
return trimmed
}
return string(runes[:maxRunes-1]) + "…"
}
// handleRegenPicoToken rotates the raw Pico WebSocket token and returns
// non-secret connection info for the launcher UI.
//

View file

@ -11,9 +11,12 @@ import (
"strconv"
"strings"
"testing"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/memory"
ppid "github.com/sipeed/picoclaw/pkg/pid"
"github.com/sipeed/picoclaw/pkg/providers"
)
func newPicoProxyRequest(method, path string) *http.Request {
@ -821,6 +824,188 @@ func TestHandlePicoMediaProxyUsesRawBearerToken(t *testing.T) {
}
}
func TestHandleGetPicoMemoryGraph_BuildsWorkspaceAndSessionGraph(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
workspaceDir := filepath.Join(t.TempDir(), "workspace")
sessionsDir := filepath.Join(workspaceDir, "sessions")
memoryDir := filepath.Join(workspaceDir, "memory")
if err := os.MkdirAll(memoryDir, 0o755); err != nil {
t.Fatalf("MkdirAll(memoryDir) error = %v", err)
}
if err := os.MkdirAll(filepath.Join(memoryDir, time.Now().Format("200601")), 0o755); err != nil {
t.Fatalf("MkdirAll(dailyNoteDir) error = %v", err)
}
cfg := config.DefaultConfig()
cfg.Agents.Defaults.Workspace = workspaceDir
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
store, err := memory.NewJSONLStore(sessionsDir)
if err != nil {
t.Fatalf("NewJSONLStore() error = %v", err)
}
sessionKey := legacyPicoSessionPrefix + "graph-session"
for _, msg := range []providers.Message{
{Role: "user", Content: "Remember the Nairobi deployment notes."},
{Role: "assistant", Content: "Saved the deployment note and linked it to workspace memory."},
} {
if err := store.AddFullMessage(nil, sessionKey, msg); err != nil {
t.Fatalf("AddFullMessage() error = %v", err)
}
}
if err := store.SetSummary(nil, sessionKey, "Graph session"); err != nil {
t.Fatalf("SetSummary() error = %v", err)
}
if err := os.WriteFile(
filepath.Join(memoryDir, "MEMORY.md"),
[]byte("# Preferences\n- User prefers network graph views\n# Projects\n- PicoClaw cockpit integration"),
0o644,
); err != nil {
t.Fatalf("WriteFile(MEMORY.md) error = %v", err)
}
todayPath := filepath.Join(memoryDir, time.Now().Format("200601"), time.Now().Format("20060102")+".md")
if err := os.WriteFile(
todayPath,
[]byte("# Daily\n- Reviewed active session memory graph"),
0o644,
); err != nil {
t.Fatalf("WriteFile(todayPath) error = %v", err)
}
h := NewHandler(configPath)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
req := httptest.NewRequest(http.MethodGet, "/api/pico/memory-graph?session_id=graph-session", nil)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp picoMemoryGraphResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("Decode() error = %v", err)
}
if resp.SessionID != "graph-session" {
t.Fatalf("resp.SessionID = %q, want %q", resp.SessionID, "graph-session")
}
if len(resp.Nodes) < 5 {
t.Fatalf("len(resp.Nodes) = %d, want at least 5", len(resp.Nodes))
}
foundMemoryRoot := false
foundSessionMessage := false
for _, node := range resp.Nodes {
if node.ID == "memory-root" {
foundMemoryRoot = true
}
if strings.Contains(node.Label, "NAIROBI") || strings.Contains(node.Preview, "Nairobi") {
foundSessionMessage = true
}
}
if !foundMemoryRoot {
t.Fatal("expected memory-root node in graph")
}
if !foundSessionMessage {
t.Fatal("expected session content to appear in graph nodes")
}
}
func TestHandleGetPicoSubagents_UsesScopedGatewayStatus(t *testing.T) {
home := t.TempDir()
t.Setenv("PICOCLAW_HOME", home)
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/internal/subagents/status" {
t.Fatalf("path = %q, want %q", r.URL.Path, "/internal/subagents/status")
}
if got := r.Header.Get("Authorization"); got != "Bearer gateway-auth-token" {
t.Fatalf("Authorization = %q, want %q", got, "Bearer gateway-auth-token")
}
if got := r.URL.Query().Get("channel"); got != "pico" {
t.Fatalf("channel query = %q, want %q", got, "pico")
}
if got := r.URL.Query().Get("chat_id"); got != "session-42" {
t.Fatalf("chat_id query = %q, want %q", got, "session-42")
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"channel": "pico",
"chat_id": "session-42",
"tasks": []map[string]any{
{
"id": "subagent-1",
"label": "Research",
"status": "running",
"created": int64(1710000000000),
"result": "This is a very long status summary that should still round-trip through the launcher API cleanly.",
},
},
})
}))
defer server.Close()
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = mustGatewayTestPort(t, server.URL)
if err := config.SaveConfig(configPath, cfg); err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
cmd := startGatewayLikeProcess(t)
t.Cleanup(func() {
if cmd.Process != nil {
_ = cmd.Process.Kill()
}
_ = cmd.Wait()
})
origPidData := gateway.pidData
origCmd := gateway.cmd
t.Cleanup(func() {
gateway.mu.Lock()
gateway.pidData = origPidData
gateway.cmd = origCmd
gateway.mu.Unlock()
})
gateway.mu.Lock()
gateway.pidData = &ppid.PidFileData{PID: cmd.Process.Pid, Token: "gateway-auth-token"}
gateway.cmd = cmd
gateway.mu.Unlock()
req := httptest.NewRequest(http.MethodGet, "http://launcher.local/api/pico/subagents?session_id=session-42", nil)
rec := httptest.NewRecorder()
h.handleGetPicoSubagents(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
var resp picoSubagentStatusResponse
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
if resp.SessionID != "session-42" || resp.ChatID != "session-42" || resp.Channel != "pico" {
t.Fatalf("response = %#v, want scoped session metadata", resp)
}
if len(resp.Tasks) != 1 || resp.Tasks[0].ID != "subagent-1" {
t.Fatalf("tasks = %#v, want one proxied task", resp.Tasks)
}
}
func TestHandleWebSocketProxyRejectsStalePidDataAfterProcessExit(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)

View file

@ -15,6 +15,43 @@ interface PicoSetupResponse {
changed: boolean
}
export interface PicoSubagentStatusItem {
id: string
label?: string
status: "running" | "completed" | "failed" | "canceled" | string
created: number
result?: string
}
export interface PicoSubagentStatusResponse {
session_id: string
channel: string
chat_id: string
tasks: PicoSubagentStatusItem[]
}
export interface PicoMemoryGraphNode {
id: string
label: string
kind: string
group: string
preview?: string
weight?: number
}
export interface PicoMemoryGraphEdge {
source: string
target: string
kind: string
}
export interface PicoMemoryGraphResponse {
session_id: string
generated_at: string
nodes: PicoMemoryGraphNode[]
edges: PicoMemoryGraphEdge[]
}
const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
@ -37,4 +74,22 @@ export async function setupPico(): Promise<PicoSetupResponse> {
return request<PicoSetupResponse>("/api/pico/setup", { method: "POST" })
}
export async function getPicoSubagents(
sessionId: string,
): Promise<PicoSubagentStatusResponse> {
const params = new URLSearchParams({ session_id: sessionId })
return request<PicoSubagentStatusResponse>(
`/api/pico/subagents?${params.toString()}`,
)
}
export async function getPicoMemoryGraph(
sessionId: string,
): Promise<PicoMemoryGraphResponse> {
const params = new URLSearchParams({ session_id: sessionId })
return request<PicoMemoryGraphResponse>(
`/api/pico/memory-graph?${params.toString()}`,
)
}
export type { PicoInfoResponse, PicoSetupResponse }

View file

@ -0,0 +1,208 @@
import dayjs from "dayjs"
import { useMemo } from "react"
import { IconArrowRight } from "@tabler/icons-react"
import { usePicoChat } from "@/hooks/use-pico-chat"
import { Badge } from "@/components/ui/badge"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { MemoryGraph } from "./memory-graph"
import { useAgentCockpit } from "./use-agent-cockpit"
function reasonLabel(reasonCode?: string) {
switch (reasonCode) {
case "requires_subagent":
return "Requires subagent runtime"
case "requires_skills":
return "Requires skills support"
case "requires_mcp_discovery":
return "Requires MCP discovery"
case "requires_linux":
return "Linux only"
case "requires_serial_platform":
return "Unsupported serial platform"
default:
return reasonCode ?? ""
}
}
export function CockpitPage() {
const { activeSessionId } = usePicoChat()
const {
groupedTools,
pendingToolName,
sessionSubagents,
sessionMemoryGraph,
toggleTool,
} = useAgentCockpit(activeSessionId)
const filteredToolCount = useMemo(
() => groupedTools.reduce((total, [, items]) => total + items.length, 0),
[groupedTools],
)
return (
<div className="flex h-full flex-col overflow-hidden bg-[#050505] text-[#F2F2F2] selection:bg-[#F27D26] selection:text-black font-sans relative">
{/* Ghost Background Typography */}
<div className="absolute inset-0 overflow-hidden pointer-events-none select-none opacity-[0.05]">
<span className="absolute -left-20 -top-10 text-[35vw] font-black leading-none uppercase">COCKPIT</span>
<span className="absolute -right-20 -bottom-20 text-[25vw] font-black leading-none uppercase">SYSTEM</span>
</div>
{/* Header */}
<header className="flex justify-between items-start border-b border-white/10 p-6 md:px-12 md:py-8 z-10">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Terminal Status</span>
<span className="text-xs opacity-60 font-mono tracking-tighter">CONNECTED / {dayjs().format('DD.MM.YYYY')}</span>
</div>
<div className="flex flex-col gap-1 text-right">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Runtime Epoch</span>
<span className="text-xs opacity-60 font-mono tracking-tighter">{dayjs().format('HH:mm')} GMT+1</span>
</div>
</header>
<div className="flex-1 overflow-auto px-6 py-6 md:px-12 md:py-10 z-10">
<div className="mx-auto grid w-full max-w-[1600px] gap-12 xl:grid-cols-[1fr_380px]">
{/* Main Workspace */}
<div className="space-y-16">
{/* Hero Section */}
<div className="relative">
<h1 className="text-[12vw] leading-[0.85] font-black tracking-[-0.07em] uppercase m-0 p-0 text-[#F2F2F2]">
AGENT<br/>INTERFACE
</h1>
<p className="mt-8 text-xl font-light max-w-xl opacity-60 leading-relaxed border-l-2 border-[#F27D26] pl-6">
Active control node for autonomous agents. Managing tool surfaces, memory networks.
</p>
</div>
<section className="grid gap-12">
{/* Tool Grid */}
<div className="space-y-8">
<div className="flex items-end justify-between border-b border-white/10 pb-4">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Active Modules</span>
<h2 className="text-4xl font-black uppercase tracking-tight">Tool Grid</h2>
</div>
<div className="flex items-center gap-6">
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">{filteredToolCount} Visible</span>
<a href="/agent/tools" className="text-xs font-bold uppercase tracking-widest hover:text-[#F27D26] transition-colors border-b border-white/20 pb-1">
Configuration
</a>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
{groupedTools.flatMap(([, items]) =>
items.map((tool) => (
<div
key={tool.name}
className="group relative border border-white/10 bg-[#0A0A0A] p-4 hover:border-[#F27D26]/50 transition-all duration-300"
>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="space-y-1 flex-1 min-w-0">
<h4 className="font-bold text-sm tracking-tight uppercase truncate group-hover:text-[#F27D26] transition-colors">
{tool.name}
</h4>
<Badge
className={cn(
"rounded-none text-[8px] uppercase tracking-widest font-black px-1.5 py-0.5",
tool.status === "enabled" ? "bg-[#F27D26] text-black" : "bg-white/10 text-white/40"
)}
>
{tool.status}
</Badge>
</div>
<Switch
checked={tool.status !== "disabled"}
disabled={pendingToolName === tool.name}
onCheckedChange={(checked) => toggleTool(tool.name, checked)}
/>
</div>
<p className="text-xs text-[#F2F2F2]/60 leading-relaxed font-light mb-4 line-clamp-2">
{tool.description}
</p>
<div className="flex items-center justify-between text-[8px] font-mono uppercase tracking-widest text-white/30 pt-3 border-t border-white/5">
<span className="truncate">{tool.config_key}</span>
{(tool as any).reason_code && (
<span className="text-[#F27D26]/60">{reasonLabel((tool as any).reason_code)}</span>
)}
</div>
</div>
))
)}
</div>
</div>
{/* Memory Network */}
<div className="space-y-8">
<div className="flex items-end justify-between border-b border-white/10 pb-4">
<div className="flex flex-col gap-1">
<span className="text-[10px] uppercase tracking-[0.22em] font-bold text-[#F27D26]">Relational Map</span>
<h2 className="text-4xl font-black uppercase tracking-tight">Memory Network</h2>
</div>
</div>
<div className="p-8 border border-white/10 bg-[#0A0A0A] relative overflow-hidden">
<MemoryGraph
nodes={sessionMemoryGraph?.nodes ?? []}
edges={sessionMemoryGraph?.edges ?? []}
/>
</div>
</div>
</section>
</div>
{/* Right Sidebar */}
<aside className="space-y-12">
{/* Subagents */}
<div className="space-y-8">
<div className="border-b border-white/10 pb-2">
<span className="text-[10px] uppercase tracking-[0.3em] font-bold text-[#F27D26]">Subagent Manifest</span>
</div>
<div className="space-y-4">
{sessionSubagents.length === 0 ? (
<div className="border border-white/10 p-5 bg-[#0A0A0A] text-sm text-white/40">
No subagents have been created in this session yet.
</div>
) : (
sessionSubagents.map((task) => (
<div key={task.id} className="group border border-white/10 p-5 bg-[#0A0A0A] hover:border-white/30 transition-all">
<div className="flex justify-between items-start mb-2">
<span className="font-bold text-sm uppercase tracking-tight">{task.label || task.id}</span>
<span className={cn(
"text-[9px] uppercase font-mono px-1.5 py-0.5",
task.status === "completed" ? "bg-green-500/20 text-green-400" : "bg-[#F27D26]/20 text-[#F27D26]"
)}>
{task.status}
</span>
</div>
<div className="text-[9px] font-mono text-white/30 truncate">
{dayjs(task.created).format("HH:mm:ss [UTC]")}
</div>
</div>
))
)}
</div>
</div>
</aside>
</div>
</div>
{/* Footer */}
<footer className="h-20 border-t border-white/10 flex items-center justify-between px-6 md:px-12 z-10 bg-[#050505]">
<nav className="flex gap-10 text-[10px] font-bold uppercase tracking-[0.3em]">
<a href="#" className="hover:text-[#F27D26] transition-colors">Portfolio</a>
<a href="#" className="hover:text-[#F27D26] transition-colors">Documentation</a>
<a href="#" className="hover:text-[#F27D26] transition-colors">Gateway</a>
</nav>
<div className="flex items-center gap-6">
<span className="text-[10px] uppercase tracking-[0.2em] opacity-40 font-bold">System Ref: BOLD-UX-01</span>
<div className="h-10 w-10 rounded-full border border-white/10 flex items-center justify-center hover:bg-white hover:text-black cursor-pointer transition-all">
<IconArrowRight size={16} />
</div>
</div>
</footer>
</div>
)
}

View file

@ -0,0 +1,231 @@
import { useMemo, useState } from "react"
import type {
PicoMemoryGraphEdge,
PicoMemoryGraphNode,
} from "@/api/pico"
import { Badge } from "@/components/ui/badge"
import { cn } from "@/lib/utils"
const VIEWBOX_WIDTH = 1000
const VIEWBOX_HEIGHT = 560
type PositionedNode = PicoMemoryGraphNode & { x: number; y: number }
interface MemoryGraphProps {
nodes: PicoMemoryGraphNode[]
edges: PicoMemoryGraphEdge[]
}
const GROUP_COLUMNS: Record<string, number> = {
memory: 170,
"daily-1": 295,
"daily-2": 295,
"daily-3": 295,
session: 700,
tool: 840,
media: 920,
}
function groupColor(group: string) {
if (group === "memory") return "#72f0a0"
if (group.startsWith("daily-")) return "#4dd0e1"
if (group === "tool") return "#f8d66d"
if (group === "media") return "#f395d6"
return "#90e89f"
}
function computeLayout(nodes: PicoMemoryGraphNode[]): PositionedNode[] {
const groups = new Map<string, PicoMemoryGraphNode[]>()
for (const node of nodes) {
const items = groups.get(node.group) ?? []
items.push(node)
groups.set(node.group, items)
}
const layout: PositionedNode[] = []
for (const [group, items] of groups.entries()) {
const x = GROUP_COLUMNS[group] ?? 500
const count = items.length
const gap = VIEWBOX_HEIGHT / (count + 1)
items.forEach((node, index) => {
const offset =
group === "session" && node.kind === "root"
? 0
: Math.sin((index + 1) * 1.7) * 12
layout.push({
...node,
x,
y: Math.max(52, Math.min(VIEWBOX_HEIGHT - 52, gap * (index + 1) + offset)),
})
})
}
return layout.sort((left, right) => left.x - right.x || left.y - right.y)
}
export function MemoryGraph({ nodes, edges }: MemoryGraphProps) {
const [selectedId, setSelectedId] = useState<string | null>(nodes[0]?.id ?? null)
const positionedNodes = useMemo(() => computeLayout(nodes), [nodes])
const nodeMap = useMemo(
() => new Map(positionedNodes.map((node) => [node.id, node])),
[positionedNodes],
)
const selectedNode =
positionedNodes.find((node) => node.id === selectedId) ?? positionedNodes[0]
return (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_280px]">
<div className="overflow-hidden rounded-xl border border-[#173621] bg-[#041008]">
<svg
viewBox={`0 0 ${VIEWBOX_WIDTH} ${VIEWBOX_HEIGHT}`}
className="h-[420px] w-full"
role="img"
aria-label="PicoClaw memory graph"
>
<defs>
<linearGradient id="memoryGrid" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#0c2014" />
<stop offset="100%" stopColor="#050d08" />
</linearGradient>
</defs>
<rect width={VIEWBOX_WIDTH} height={VIEWBOX_HEIGHT} fill="url(#memoryGrid)" />
{Array.from({ length: 10 }).map((_, index) => (
<line
key={`v-${index}`}
x1={(VIEWBOX_WIDTH / 10) * index}
y1="0"
x2={(VIEWBOX_WIDTH / 10) * index}
y2={VIEWBOX_HEIGHT}
stroke="#0d2817"
strokeWidth="1"
/>
))}
{Array.from({ length: 8 }).map((_, index) => (
<line
key={`h-${index}`}
x1="0"
y1={(VIEWBOX_HEIGHT / 8) * index}
x2={VIEWBOX_WIDTH}
y2={(VIEWBOX_HEIGHT / 8) * index}
stroke="#0d2817"
strokeWidth="1"
/>
))}
{edges.map((edge) => {
const source = nodeMap.get(edge.source)
const target = nodeMap.get(edge.target)
if (!source || !target) {
return null
}
const active =
selectedId != null &&
(selectedId === edge.source || selectedId === edge.target)
return (
<line
key={`${edge.source}-${edge.target}-${edge.kind}`}
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke={active ? "#9dfdbb" : "#1f5c34"}
strokeOpacity={active ? 0.9 : 0.45}
strokeWidth={active ? 2.5 : 1.4}
/>
)
})}
{positionedNodes.map((node) => {
const active = node.id === selectedNode?.id
const color = groupColor(node.group)
const radius =
node.kind === "root" ? 24 : node.kind === "document" ? 18 : 14
return (
<g
key={node.id}
onMouseEnter={() => setSelectedId(node.id)}
onClick={() => setSelectedId(node.id)}
className="cursor-pointer"
>
<circle
cx={node.x}
cy={node.y}
r={radius + 10}
fill={active ? `${color}22` : "transparent"}
/>
<circle
cx={node.x}
cy={node.y}
r={radius}
fill="#07110a"
stroke={color}
strokeWidth={active ? 3 : 2}
/>
<text
x={node.x}
y={node.y + 4}
textAnchor="middle"
fill={color}
fontSize={node.kind === "root" ? "12" : "10"}
fontFamily="ui-monospace, SFMono-Regular, Menlo, monospace"
>
{node.kind === "root" ? node.label : node.label.slice(0, 18)}
</text>
</g>
)
})}
</svg>
</div>
<div className="space-y-3 rounded-xl border border-[#173621] bg-[#050d08] p-4">
{selectedNode ? (
<>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-mono text-sm uppercase tracking-[0.22em] text-[#95d7a5]">
Node Focus
</p>
<h3 className="mt-2 text-sm font-semibold text-[#effff3]">
{selectedNode.label}
</h3>
</div>
<Badge
variant="outline"
className={cn(
"border-[#2a6b3d] text-[#9fd8ae]",
selectedNode.kind === "tool" && "border-[#8c7931] text-[#f8d66d]",
)}
>
{selectedNode.kind}
</Badge>
</div>
<p className="text-sm leading-relaxed text-[#9cc8a8]">
{selectedNode.preview || "No extra preview for this node yet."}
</p>
<div className="grid gap-2 text-xs text-[#74a282]">
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
<span>Cluster</span>
<span className="font-mono uppercase text-[#d7f9df]">
{selectedNode.group}
</span>
</div>
<div className="flex items-center justify-between rounded-lg border border-[#13301d] px-3 py-2">
<span>Weight</span>
<span className="font-mono text-[#d7f9df]">
{selectedNode.weight ?? 1}
</span>
</div>
</div>
</>
) : (
<p className="text-sm text-[#8bb39a]">No graph data available.</p>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,136 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { useDeferredValue, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { getPicoMemoryGraph, getPicoSubagents } from "@/api/pico"
import { getTools, getWebSearchConfig, setToolEnabled } from "@/api/tools"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
type ToolStatusFilter = "all" | "enabled" | "disabled" | "blocked"
export function useAgentCockpit(sessionId: string) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [searchQuery, setSearchQuery] = useState("")
const [statusFilter, setStatusFilter] = useState<ToolStatusFilter>("all")
const deferredSearchQuery = useDeferredValue(searchQuery)
const toolsQuery = useQuery({
queryKey: ["tools"],
queryFn: getTools,
})
const webSearchQuery = useQuery({
queryKey: ["tools", "web-search-config"],
queryFn: getWebSearchConfig,
})
const subagentsQuery = useQuery({
queryKey: ["pico", "subagents", sessionId],
queryFn: () => getPicoSubagents(sessionId),
enabled: Boolean(sessionId),
refetchInterval: 3000,
})
const memoryGraphQuery = useQuery({
queryKey: ["pico", "memory-graph", sessionId],
queryFn: () => getPicoMemoryGraph(sessionId),
enabled: Boolean(sessionId),
refetchInterval: 10000,
})
const toggleToolMutation = useMutation({
mutationFn: async ({ name, enabled }: { name: string; enabled: boolean }) =>
setToolEnabled(name, enabled),
onSuccess: async (_, variables) => {
const gateway = await refreshGatewayState({ force: true })
showSaveSuccessOrRestartToast(
t,
variables.enabled
? t("pages.agent.tools.enable_success", "Tool enabled successfully")
: t(
"pages.agent.tools.disable_success",
"Tool disabled successfully",
),
"Agent Cockpit",
gateway?.restartRequired === true,
)
void queryClient.invalidateQueries({ queryKey: ["tools"] })
},
onError: (error) => {
toast.error(
error instanceof Error
? error.message
: t("pages.agent.tools.toggle_error", "Failed to toggle tool"),
)
},
})
const tools = toolsQuery.data?.tools ?? []
const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase()
const groupedTools = useMemo(() => {
const groups = new Map<string, typeof tools>()
for (const tool of tools) {
if (statusFilter !== "all" && tool.status !== statusFilter) {
continue
}
if (normalizedSearchQuery) {
const haystack = `${tool.name} ${tool.description}`.toLowerCase()
if (!haystack.includes(normalizedSearchQuery)) {
continue
}
}
const items = groups.get(tool.category) ?? []
items.push(tool)
groups.set(tool.category, items)
}
return Array.from(groups.entries())
}, [normalizedSearchQuery, statusFilter, tools])
const categoryCounts = useMemo(() => {
const counts = new Map<string, number>()
for (const tool of tools) {
counts.set(tool.category, (counts.get(tool.category) ?? 0) + 1)
}
return Array.from(counts.entries())
}, [tools])
const statusCounts = useMemo(() => {
return {
all: tools.length,
enabled: tools.filter((tool) => tool.status === "enabled").length,
disabled: tools.filter((tool) => tool.status === "disabled").length,
blocked: tools.filter((tool) => tool.status === "blocked").length,
}
}, [tools])
return {
categoryCounts,
groupedTools,
pendingToolName: toggleToolMutation.isPending
? (toggleToolMutation.variables?.name ?? null)
: null,
searchQuery,
sessionMemoryGraph: memoryGraphQuery.data ?? null,
sessionSubagents: subagentsQuery.data?.tasks ?? [],
statusCounts,
statusFilter,
tools,
hasMemoryGraphError: memoryGraphQuery.error != null,
webSearchConfig: webSearchQuery.data ?? null,
hasSubagentsError: subagentsQuery.error != null,
hasToolsError: toolsQuery.error != null,
isMemoryGraphLoading: memoryGraphQuery.isLoading,
isSubagentsLoading: subagentsQuery.isLoading,
isToolsLoading: toolsQuery.isLoading,
isWebSearchLoading: webSearchQuery.isLoading,
setSearchQuery,
setStatusFilter,
toggleTool: (name: string, enabled: boolean) =>
toggleToolMutation.mutate({ name, enabled }),
}
}

View file

@ -3,6 +3,7 @@ import {
IconAtom,
IconChevronsDown,
IconChevronsUp,
IconCpu,
IconKey,
IconListDetails,
IconMessageCircle,
@ -132,6 +133,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
{
...baseNavGroups[2],
items: [
{
title: "Agent Cockpit",
url: "/agent/cockpit",
icon: IconCpu,
translateTitle: false,
},
{
title: "navigation.hub",
url: "/agent/hub",

View file

@ -1,5 +1,6 @@
import { IconArrowUp, IconPhotoPlus, IconX } from "@tabler/icons-react"
import type { KeyboardEvent } from "react"
import { IconArrowUp, IconMicrophone, IconMicrophoneOff, IconPhotoPlus, IconX } from "@tabler/icons-react"
import { type KeyboardEvent } from "react"
import { useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import TextareaAutosize from "react-textarea-autosize"
@ -13,6 +14,28 @@ import {
import { cn } from "@/lib/utils"
import type { ChatAttachment, ContextUsage } from "@/store/chat"
declare global {
interface Window {
SpeechRecognition?: new () => SpeechRecognitionLike
webkitSpeechRecognition?: new () => SpeechRecognitionLike
}
}
interface SpeechRecognitionLike {
continuous: boolean
interimResults: boolean
lang: string
onresult: ((event: SpeechRecognitionEventLike) => void) | null
onend: (() => void) | null
onerror: ((event: { error: string }) => void) | null
start(): void
stop(): void
}
interface SpeechRecognitionEventLike {
results: ArrayLike<ArrayLike<{ transcript: string }>>
}
export type ChatInputDisabledReason =
| "gatewayUnknown"
| "gatewayStarting"
@ -51,6 +74,41 @@ export function ChatComposer({
contextUsage,
}: ChatComposerProps) {
const { t } = useTranslation()
const [isListening, setIsListening] = useState(false)
const recognitionRef = useRef<SpeechRecognitionLike | null>(null)
useEffect(() => {
const Recognition =
window.SpeechRecognition ?? window.webkitSpeechRecognition
if (!Recognition) return
const recognition = new Recognition()
recognition.continuous = false
recognition.interimResults = false
recognition.lang = "en-US"
recognition.onresult = (event) => {
const transcript = event.results[0]?.[0]?.transcript?.trim() ?? ""
if (transcript) onInputChange(transcript)
}
recognition.onend = () => setIsListening(false)
recognition.onerror = () => setIsListening(false)
recognitionRef.current = recognition
}, [onInputChange])
const toggleVoice = () => {
if (!recognitionRef.current) return
if (isListening) {
recognitionRef.current.stop()
setIsListening(false)
} else {
try {
recognitionRef.current.start()
setIsListening(true)
} catch {
setIsListening(false)
}
}
}
const canInput = inputDisabledReason === null
const disabledMessage =
inputDisabledReason === null
@ -110,21 +168,37 @@ export function ChatComposer({
maxRows={8}
/>
<div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full"
onClick={onAddImages}
disabled={!canInput}
aria-label={t("chat.attachImage")}
title={t("chat.attachImage")}
>
<IconPhotoPlus className="size-4" />
</Button>
</div>
<div className="mt-2 flex items-center justify-between px-1">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground h-8 w-8 rounded-full"
onClick={onAddImages}
disabled={!canInput}
aria-label={t("chat.attachImage")}
title={t("chat.attachImage")}
>
<IconPhotoPlus className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"h-8 w-8 rounded-full",
isListening
? "text-red-500 bg-red-500/10 hover:bg-red-500/20"
: "text-muted-foreground hover:text-foreground"
)}
onClick={toggleVoice}
aria-label={isListening ? t("chat.stopListening") : t("chat.startListening")}
title={isListening ? t("chat.stopListening") : t("chat.startListening")}
>
{isListening ? <IconMicrophoneOff className="size-4" /> : <IconMicrophone className="size-4" />}
</Button>
</div>
<div className="flex items-center gap-1.5">
{contextUsage && (

View file

@ -23,6 +23,7 @@ import { Route as ChannelsNameRouteImport } from './routes/channels/$name'
import { Route as AgentToolsRouteImport } from './routes/agent/tools'
import { Route as AgentSkillsRouteImport } from './routes/agent/skills'
import { Route as AgentHubRouteImport } from './routes/agent/hub'
import { Route as AgentCockpitRouteImport } from './routes/agent/cockpit'
const ModelsRoute = ModelsRouteImport.update({
id: '/models',
@ -94,6 +95,11 @@ const AgentHubRoute = AgentHubRouteImport.update({
path: '/hub',
getParentRoute: () => AgentRoute,
} as any)
const AgentCockpitRoute = AgentCockpitRouteImport.update({
id: '/cockpit',
path: '/cockpit',
getParentRoute: () => AgentRoute,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@ -105,6 +111,7 @@ export interface FileRoutesByFullPath {
'/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/cockpit': typeof AgentCockpitRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
@ -121,6 +128,7 @@ export interface FileRoutesByTo {
'/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/cockpit': typeof AgentCockpitRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
@ -138,6 +146,7 @@ export interface FileRoutesById {
'/launcher-setup': typeof LauncherSetupRoute
'/logs': typeof LogsRoute
'/models': typeof ModelsRoute
'/agent/cockpit': typeof AgentCockpitRoute
'/agent/hub': typeof AgentHubRoute
'/agent/skills': typeof AgentSkillsRoute
'/agent/tools': typeof AgentToolsRoute
@ -156,6 +165,7 @@ export interface FileRouteTypes {
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/cockpit'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
@ -172,6 +182,7 @@ export interface FileRouteTypes {
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/cockpit'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
@ -188,6 +199,7 @@ export interface FileRouteTypes {
| '/launcher-setup'
| '/logs'
| '/models'
| '/agent/cockpit'
| '/agent/hub'
| '/agent/skills'
| '/agent/tools'
@ -307,6 +319,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AgentHubRouteImport
parentRoute: typeof AgentRoute
}
'/agent/cockpit': {
id: '/agent/cockpit'
path: '/cockpit'
fullPath: '/agent/cockpit'
preLoaderRoute: typeof AgentCockpitRouteImport
parentRoute: typeof AgentRoute
}
}
}
@ -323,12 +342,14 @@ const ChannelsRouteRouteWithChildren = ChannelsRouteRoute._addFileChildren(
)
interface AgentRouteChildren {
AgentCockpitRoute: typeof AgentCockpitRoute
AgentHubRoute: typeof AgentHubRoute
AgentSkillsRoute: typeof AgentSkillsRoute
AgentToolsRoute: typeof AgentToolsRoute
}
const AgentRouteChildren: AgentRouteChildren = {
AgentCockpitRoute: AgentCockpitRoute,
AgentHubRoute: AgentHubRoute,
AgentSkillsRoute: AgentSkillsRoute,
AgentToolsRoute: AgentToolsRoute,

View file

@ -0,0 +1,11 @@
import { createFileRoute } from "@tanstack/react-router"
import { CockpitPage } from "@/components/agent/cockpit/cockpit-page"
export const Route = createFileRoute("/agent/cockpit")({
component: AgentCockpitRoute,
})
function AgentCockpitRoute() {
return <CockpitPage />
}