feat: adapt FreeRide skill for PicoClaw

This commit is contained in:
stevef 2026-04-19 08:37:27 +02:00
parent 6ac2a46680
commit cd41d44085
5 changed files with 340 additions and 9 deletions

View file

@ -57,6 +57,11 @@ spec:
secretKeyRef:
name: picoclaw-secrets
key: telegram-token
- name: OPENROUTER_API_KEY
valueFrom:
secretKeyRef:
name: picoclaw-secrets
key: OPENROUTER_API_KEY
volumeMounts:
- name: picoclaw-data
mountPath: /home/picoclaw/.picoclaw

View file

@ -9,3 +9,4 @@ stringData:
telegram-token: "YOUR_TELEGRAM_TOKEN_HERE"
nvidia-api-key: "YOUR_NVIDIA_API_KEY_HERE"
azure-api-key: "YOUR_AZURE_API_KEY_HERE"
OPENROUTER_API_KEY: "YOUR_OPENROUTER_API_KEY_HERE"

View file

@ -78,6 +78,7 @@ type AgentLoop struct {
activeRequests sync.WaitGroup
reloadFunc func() error
configPath string
}
// processOptions configures how a message is processed
@ -124,6 +125,7 @@ const (
func NewAgentLoop(
cfg *config.Config,
configPath string,
msgBus *bus.MessageBus,
provider providers.LLMProvider,
) *AgentLoop {
@ -151,14 +153,15 @@ func NewAgentLoop(
eventBus := NewEventBus()
al := &AgentLoop{
bus: msgBus,
cfg: cfg,
registry: registry,
state: stateManager,
eventBus: eventBus,
fallback: fallbackChain,
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
bus: msgBus,
cfg: cfg,
configPath: configPath,
registry: registry,
state: stateManager,
eventBus: eventBus,
fallback: fallbackChain,
cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()),
steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)),
}
al.agentCacheTTL = 24 * time.Hour
@ -336,6 +339,10 @@ func registerSharedTools(
// Skill discovery and installation tools
skills_enabled := cfg.Tools.IsToolEnabled("skills")
if skills_enabled {
agent.Tools.Register(tools.NewFreeRideTool(al.GetConfigPath(), al.GetReloadFunc()))
}
find_skills_enable := cfg.Tools.IsToolEnabled("find_skills")
install_skills_enable := cfg.Tools.IsToolEnabled("install_skill")
if skills_enabled && (find_skills_enable || install_skills_enable) {
@ -1209,6 +1216,16 @@ func (al *AgentLoop) SetReloadFunc(fn func() error) {
al.reloadFunc = fn
}
// GetReloadFunc returns the current reload callback.
func (al *AgentLoop) GetReloadFunc() func() error {
return al.reloadFunc
}
// GetConfigPath returns the path to the configuration file.
func (al *AgentLoop) GetConfigPath() string {
return al.configPath
}
var audioAnnotationRe = regexp.MustCompile(`\[(voice|audio)(?::[^\]]*)?\]`)
// transcribeAudioInMessage resolves audio media refs, transcribes them, and

View file

@ -182,7 +182,7 @@ func Run(debug bool, homePath, configPath string, allowEmptyStartup bool) error
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
agentLoop := agent.NewAgentLoop(cfg, configPath, msgBus, provider)
fmt.Println("\n📦 Agent Status:")
startupInfo := agentLoop.GetStartupInfo()

308
pkg/tools/freeride.go Normal file
View file

@ -0,0 +1,308 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sort"
"strings"
"time"
"github.com/sipeed/picoclaw/pkg/config"
"github.com/sipeed/picoclaw/pkg/logger"
)
// FreeRideTool adapts the FreeRide logic (from clawhub/free-ride) for PicoClaw.
// It manages OpenRouter's free models and configures them as fallbacks.
type FreeRideTool struct {
configPath string
reloadFunc func() error
}
func NewFreeRideTool(configPath string, reloadFunc func() error) *FreeRideTool {
return &FreeRideTool{
configPath: configPath,
reloadFunc: reloadFunc,
}
}
func (t *FreeRideTool) Name() string {
return "freeride"
}
func (t *FreeRideTool) Description() string {
return "FreeRide gives you unlimited free AI in PicoClaw by automatically managing OpenRouter's free models. " +
"Use 'auto' to configure best model + fallbacks, or 'list' to see available free models."
}
func (t *FreeRideTool) Parameters() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{
"type": "string",
"enum": []string{"auto", "list", "status"},
"description": "The command to run: 'auto' (configures models), 'list' (shows free models), 'status' (checks current setup)",
},
"limit": map[string]any{
"type": "integer",
"description": "For 'list', how many models to show. For 'auto', how many fallbacks to configure.",
"default": 5,
},
},
"required": []string{"command"},
}
}
type openRouterModel struct {
ID string `json:"id"`
Name string `json:"name"`
ContextLength int `json:"context_length"`
Pricing struct {
Prompt string `json:"prompt"`
Completion string `json:"completion"`
} `json:"pricing"`
SupportedParameters []string `json:"supported_parameters"`
Created int64 `json:"created"`
}
func (t *FreeRideTool) Execute(ctx context.Context, args map[string]any) *ToolResult {
cmd, _ := args["command"].(string)
limit := 5
if l, ok := args["limit"].(float64); ok {
limit = int(l)
}
switch cmd {
case "list":
return t.handleList(ctx, limit)
case "auto":
return t.handleAuto(ctx, limit)
case "status":
return t.handleStatus()
default:
return ErrorResult(fmt.Sprintf("unknown command: %s", cmd))
}
}
func (t *FreeRideTool) fetchFreeModels(ctx context.Context) ([]openRouterModel, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://openrouter.ai/api/v1/models", nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("OpenRouter API returned status %d", resp.StatusCode)
}
var wrapper struct {
Data []openRouterModel `json:"data"`
}
if err := json.NewDecoder(resp.Body).Decode(&wrapper); err != nil {
return nil, err
}
var freeModels []openRouterModel
for _, m := range wrapper.Data {
if m.Pricing.Prompt == "0" || m.Pricing.Prompt == "0.0" || m.Pricing.Prompt == "0.00" {
freeModels = append(freeModels, m)
}
}
// Rank models
sort.Slice(freeModels, func(i, j int) bool {
return scoreModel(freeModels[i]) > scoreModel(freeModels[j])
})
return freeModels, nil
}
func scoreModel(m openRouterModel) float64 {
score := 0.0
// Context length (40%) - normalize against 128k
ctxScore := float64(m.ContextLength) / 128000.0
if ctxScore > 1.0 {
ctxScore = 1.0
}
score += ctxScore * 0.4
// Capabilities (30%) - tools, vision, prompt caching, etc.
capabilityScore := 0.0
for _, p := range m.SupportedParameters {
if p == "tools" {
capabilityScore += 0.5
}
if p == "response_format" {
capabilityScore += 0.5
}
}
if capabilityScore > 1.0 {
capabilityScore = 1.0
}
score += capabilityScore * 0.3
// Recency (20%) - newer is better
// Normalize against 2 years ago
twoYearsAgo := time.Now().AddDate(-2, 0, 0).Unix()
now := time.Now().Unix()
if m.Created > twoYearsAgo {
recencyScore := float64(m.Created-twoYearsAgo) / float64(now-twoYearsAgo)
score += recencyScore * 0.2
}
// Provider Trust (10%) - hardcoded list of trusted names
trustNames := []string{"google", "meta", "nvidia", "mistral", "anthropic", "openai", "microsoft", "qwen", "deepseek"}
for _, name := range trustNames {
if strings.Contains(strings.ToLower(m.ID), name) {
score += 0.1
break
}
}
return score
}
func (t *FreeRideTool) handleList(ctx context.Context, limit int) *ToolResult {
models, err := t.fetchFreeModels(ctx)
if err != nil {
return ErrorResult(fmt.Errorf("failed to fetch models: %w", err).Error())
}
if len(models) == 0 {
return SilentResult("No free models found on OpenRouter.")
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("Found %d free models on OpenRouter (ranked by quality):\n\n", len(models)))
for i, m := range models {
if i >= limit {
break
}
sb.WriteString(fmt.Sprintf("%d. **%s** (%s)\n", i+1, m.Name, m.ID))
sb.WriteString(fmt.Sprintf(" Context: %d tokens | Score: %.2f\n", m.ContextLength, scoreModel(m)))
sb.WriteString(fmt.Sprintf(" Parameters: %s\n\n", strings.Join(m.SupportedParameters, ", ")))
}
return SilentResult(sb.String())
}
func (t *FreeRideTool) handleAuto(ctx context.Context, limit int) *ToolResult {
models, err := t.fetchFreeModels(ctx)
if err != nil {
return ErrorResult(fmt.Errorf("failed to fetch models: %w", err).Error())
}
if len(models) == 0 {
return ErrorResult("No free models found on OpenRouter.")
}
cfgObj, err := config.LoadConfig(t.configPath)
if err != nil {
return ErrorResult(fmt.Errorf("failed to load config: %w", err).Error())
}
// 1. Add models to ModelList if not present
var addedModels []string
for i, m := range models {
if i >= limit {
break
}
modelName := strings.ReplaceAll(m.ID, "/", "-")
if !modelExists(cfgObj, modelName) {
mc := &config.ModelConfig{
ModelName: modelName,
Model: "openrouter/" + m.ID,
Enabled: true,
}
mc.SetAPIKey("env://OPENROUTER_API_KEY")
cfgObj.ModelList = append(cfgObj.ModelList, mc)
addedModels = append(addedModels, modelName)
}
}
// 2. Set fallbacks for the default agent
if len(addedModels) > 0 {
// Update AgentDefaults fallbacks
cfgObj.Agents.Defaults.ModelFallbacks = append(cfgObj.Agents.Defaults.ModelFallbacks, addedModels...)
// Deduplicate fallbacks
cfgObj.Agents.Defaults.ModelFallbacks = uniqueStrings(cfgObj.Agents.Defaults.ModelFallbacks)
if err := config.SaveConfig(t.configPath, cfgObj); err != nil {
return ErrorResult(fmt.Errorf("failed to save config: %w", err).Error())
}
msg := fmt.Sprintf("Success! Added %d free models as fallbacks: %s.\n", len(addedModels), strings.Join(addedModels, ", "))
msg += "Re-loading configuration to apply changes..."
if t.reloadFunc != nil {
if err := t.reloadFunc(); err != nil {
return ErrorResult(fmt.Sprintf("%s\nFailed to reload: %v", msg, err))
}
}
return SilentResult(msg)
}
return SilentResult("No new free models to add. Your configuration is up to date.")
}
func (t *FreeRideTool) handleStatus() *ToolResult {
cfgObj, err := config.LoadConfig(t.configPath)
if err != nil {
return ErrorResult(fmt.Errorf("failed to load config: %w", err).Error())
}
var sb strings.Builder
sb.WriteString("FreeRide Status:\n")
sb.WriteString(fmt.Sprintf("- Primary Model: %s\n", cfgObj.Agents.Defaults.GetModelName()))
sb.WriteString(fmt.Sprintf("- Fallback Models: %s\n", strings.Join(cfgObj.Agents.Defaults.ModelFallbacks, ", ")))
// Check for OpenRouter models in fallbacks
openRouterCount := 0
for _, fb := range cfgObj.Agents.Defaults.ModelFallbacks {
if strings.Contains(strings.ToLower(fb), "openrouter") || isKnownOpenRouterAlias(cfgObj, fb) {
openRouterCount++
}
}
sb.WriteString(fmt.Sprintf("- Managed Free Models: %d\n", openRouterCount))
return SilentResult(sb.String())
}
func modelExists(cfg *config.Config, modelName string) bool {
for _, m := range cfg.ModelList {
if m.ModelName == modelName {
return true
}
}
return false
}
func isKnownOpenRouterAlias(cfg *config.Config, modelName string) bool {
for _, m := range cfg.ModelList {
if m.ModelName == modelName && strings.HasPrefix(m.Model, "openrouter/") {
return true
}
}
return false
}
func uniqueStrings(input []string) []string {
keys := make(map[string]bool)
list := []string{}
for _, entry := range input {
if _, value := keys[entry]; !value {
keys[entry] = true
list = append(list, entry)
}
}
return list
}