picoclaw/pkg/skills/registry.go
admin-mf 93fb7b8794 Merge upstream/main into sync/upstream-2026-05-07
Sync with sipeed/picoclaw upstream/main (~1095 commits since last merge),
including major restructures:

- Launcher migrated to web/backend (#1275): cmd/picoclaw-launcher/* removed.
  Our launcher is dropped (MagicForm doesn't use it; web/backend is the
  upstream replacement when needed).
- Agent loop split (#2585, 12d5421c): pkg/agent/loop.go deleted, replaced by
  pkg/agent/agent_*.go files. Multi-tenancy customizations to be forward-ported
  in follow-up commits on this branch.
- Tool packages reorganized (4c133dc2): pkg/tools/{filesystem,web}.go moved
  to pkg/tools/{fs,integration}/. Customizations to be forward-ported.
- Channels switched to map+Settings model: MagicFormConfig → MagicFormSettings
  registered in config_channel.go alongside other channels; channel constructor
  updated to (channelName, channelType, *Config, *MessageBus) factory pattern.
- Config schema updates: removed deprecated AgentDefaults.Model field;
  removed Config.Bindings (legacy migration in upstream); SessionConfig now
  uses Dimensions instead of DMScope; ExecConfig adds AllowRemote.

Conflicts resolved:
- pkg/agent/loop.go + loop_test.go: accepted upstream deletion (split)
- pkg/tools/{filesystem,web}.go: accepted upstream deletion (moved)
- cmd/picoclaw-launcher/*: dropped (deps deleted upstream, unused by MagicForm)
- cmd/picoclaw/internal/gateway/helpers.go: accepted upstream deletion
- pkg/channels/manager.go: switched to upstream's map-driven channel init
- pkg/config/config.go: merged AgentDefaults fields, kept WorkspaceRoot,
  removed deprecated Model
- pkg/bus/types.go: merged OutboundMessage with upstream's new fields
  (Context, AgentID, SessionKey, Scope, ContextUsage) plus our magicform
  callback fields (Type, Metrics, Progress, Escalation)
- pkg/tools/{cron,shell}.go: combined our customizations with upstream changes
- pkg/skills/installer.go: dropped our LimitReader path (upstream now uses
  chunked DownloadToFile which is size-bounded)

The magicform channel was rebuilt to match upstream's new API:
- *config.MagicFormSettings (SecureString token) instead of MagicFormConfig
- Send returns ([]string, error) per upstream Channel interface
- InboundMessage built with Context (with Raw map for tenancy hints) instead
  of bus.Peer + Metadata field which were removed.

Multi-tenancy hints (workspace_override, config_dir, allowed_tools,
allowed_skills) are still flowing via Context.Raw — the agent loop side of
the integration is forward-ported in a follow-up commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 12:12:44 -05:00

291 lines
8.3 KiB
Go

package skills
import (
"context"
"fmt"
"log/slog"
"path"
"strings"
"sync"
"time"
)
const (
defaultMaxConcurrentSearches = 2
)
// SearchResult represents a single result from a skill registry search.
type SearchResult struct {
Score float64 `json:"score"`
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Summary string `json:"summary"`
Version string `json:"version"`
RegistryName string `json:"registry_name"`
}
// SkillMeta holds metadata about a skill from a registry.
type SkillMeta struct {
Slug string `json:"slug"`
DisplayName string `json:"display_name"`
Summary string `json:"summary"`
LatestVersion string `json:"latest_version"`
IsMalwareBlocked bool `json:"is_malware_blocked"`
IsSuspicious bool `json:"is_suspicious"`
RegistryName string `json:"registry_name"`
}
// InstallResult is returned by DownloadAndInstall to carry metadata
// back to the caller for moderation and user messaging.
type InstallResult struct {
Version string
IsMalwareBlocked bool
IsSuspicious bool
Summary string
MetadataAvailable bool
}
// RegistryProvider creates a registry instance from configuration.
// Different hubs can implement this to plug into the shared manager.
type RegistryProvider interface {
IsEnabled() bool
BuildRegistry() SkillRegistry
}
// SkillRegistry is the interface that all skill registries must implement.
// Each registry represents a different source of skills (e.g., clawhub.ai)
type SkillRegistry interface {
// Name returns the unique name of this registry (e.g., "clawhub").
Name() string
// ResolveInstallDirName returns the directory name to use under workspace/skills
// for a given install target. Different registries can interpret the target
// differently (for example, a slug vs owner/repo/path).
ResolveInstallDirName(target string) (string, error)
// SkillURL returns the web URL for a skill slug if the registry exposes one.
// version is optional and can be used by registries whose URLs depend on a ref.
SkillURL(slug, version string) string
// Search searches the registry for skills matching the query.
Search(ctx context.Context, query string, limit int) ([]SearchResult, error)
// GetSkillMeta retrieves metadata for a specific skill by slug.
GetSkillMeta(ctx context.Context, slug string) (*SkillMeta, error)
// DownloadAndInstall fetches metadata, resolves the version, downloads and
// installs the skill to targetDir. Returns an InstallResult with metadata
// for the caller to use for moderation and user messaging.
DownloadAndInstall(ctx context.Context, slug, version, targetDir string) (*InstallResult, error)
}
// InstallTargetNormalizer is implemented by registries that can canonicalize
// user-provided install targets into a stable slug for origin metadata.
type InstallTargetNormalizer interface {
NormalizeInstallTarget(target string) string
}
func NormalizeInstallTargetForRegistryInstance(registry SkillRegistry, target string) string {
if registry == nil || target == "" {
return target
}
normalizer, ok := registry.(InstallTargetNormalizer)
if !ok {
return target
}
normalized := normalizer.NormalizeInstallTarget(target)
if normalized == "" {
return target
}
return normalized
}
// RegistryConfig holds configuration for all skill registries.
// This is the input to NewRegistryManagerFromConfig.
type RegistryConfig struct {
Providers []RegistryProvider
MaxConcurrentSearches int
}
// ClawHubConfig configures the ClawHub registry.
type ClawHubConfig struct {
Enabled bool
BaseURL string
AuthToken string
SearchPath string // e.g. "/api/v1/search"
SkillsPath string // e.g. "/api/v1/skills"
DownloadPath string // e.g. "/api/v1/download"
Timeout int // seconds, 0 = default (30s)
MaxZipSize int // bytes, 0 = default (50MB)
MaxResponseSize int // bytes, 0 = default (2MB)
}
// RegistryManager coordinates multiple skill registries.
// It fans out search requests and routes installs to the correct registry.
type RegistryManager struct {
registries []SkillRegistry
maxConcurrent int
mu sync.RWMutex
}
func ValidateInstallTarget(target string) error {
target = strings.TrimSpace(target)
if target == "" {
return fmt.Errorf("identifier is required and must be a non-empty string")
}
if strings.Contains(target, "\\") {
return fmt.Errorf("identifier %q contains invalid path separators", target)
}
clean := path.Clean("/" + target)
if clean == "/" || strings.HasPrefix(clean, "/../") || clean == "/.." {
return fmt.Errorf("identifier %q contains invalid path traversal", target)
}
if strings.Contains(target, "//") {
return fmt.Errorf("identifier %q contains empty path segments", target)
}
for _, segment := range strings.Split(strings.Trim(target, "/"), "/") {
if segment == "." || segment == ".." || segment == "" {
return fmt.Errorf("identifier %q contains invalid path segments", target)
}
}
return nil
}
// NewRegistryManager creates an empty RegistryManager.
func NewRegistryManager() *RegistryManager {
return &RegistryManager{
registries: make([]SkillRegistry, 0),
maxConcurrent: defaultMaxConcurrentSearches,
}
}
// NewRegistryManagerFromConfig builds a RegistryManager from config,
// instantiating only the enabled registries.
func NewRegistryManagerFromConfig(cfg RegistryConfig) *RegistryManager {
rm := NewRegistryManager()
if cfg.MaxConcurrentSearches > 0 {
rm.maxConcurrent = cfg.MaxConcurrentSearches
}
for _, provider := range cfg.Providers {
if provider == nil || !provider.IsEnabled() {
continue
}
registry := provider.BuildRegistry()
if registry == nil {
continue
}
rm.AddRegistry(registry)
}
return rm
}
// AddRegistry adds a registry to the manager.
func (rm *RegistryManager) AddRegistry(r SkillRegistry) {
rm.mu.Lock()
defer rm.mu.Unlock()
rm.registries = append(rm.registries, r)
}
// GetRegistry returns a registry by name, or nil if not found.
func (rm *RegistryManager) GetRegistry(name string) SkillRegistry {
rm.mu.RLock()
defer rm.mu.RUnlock()
for _, r := range rm.registries {
if r.Name() == name {
return r
}
}
return nil
}
// SearchAll fans out the query to all registries concurrently
// and merges results sorted by score descending.
func (rm *RegistryManager) SearchAll(ctx context.Context, query string, limit int) ([]SearchResult, error) {
rm.mu.RLock()
regs := make([]SkillRegistry, len(rm.registries))
copy(regs, rm.registries)
rm.mu.RUnlock()
if len(regs) == 0 {
return nil, fmt.Errorf("no registries configured")
}
type regResult struct {
results []SearchResult
err error
}
// Semaphore: limit concurrency.
sem := make(chan struct{}, rm.maxConcurrent)
resultsCh := make(chan regResult, len(regs))
var wg sync.WaitGroup
for _, reg := range regs {
wg.Add(1)
go func(r SkillRegistry) {
defer wg.Done()
// Acquire semaphore slot.
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
resultsCh <- regResult{err: ctx.Err()}
return
}
searchCtx, cancel := context.WithTimeout(ctx, 1*time.Minute)
defer cancel()
results, err := r.Search(searchCtx, query, limit)
if err != nil {
slog.Warn("registry search failed", "registry", r.Name(), "error", err)
resultsCh <- regResult{err: err}
return
}
resultsCh <- regResult{results: results}
}(reg)
}
// Close results channel after all goroutines complete.
go func() {
wg.Wait()
close(resultsCh)
}()
var merged []SearchResult
var lastErr error
var anyRegistrySucceeded bool
for rr := range resultsCh {
if rr.err != nil {
lastErr = rr.err
continue
}
anyRegistrySucceeded = true
merged = append(merged, rr.results...)
}
// If all registries failed, return the last error.
if !anyRegistrySucceeded && lastErr != nil {
return nil, fmt.Errorf("all registries failed: %w", lastErr)
}
// Sort by score descending.
sortByScoreDesc(merged)
// Clamp to limit.
if limit > 0 && len(merged) > limit {
merged = merged[:limit]
}
return merged, nil
}
// sortByScoreDesc sorts SearchResults by Score in descending order (insertion sort — small slices).
func sortByScoreDesc(results []SearchResult) {
for i := 1; i < len(results); i++ {
key := results[i]
j := i - 1
for j >= 0 && results[j].Score < key.Score {
results[j+1] = results[j]
j--
}
results[j+1] = key
}
}