mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-05-25 16:00:35 +00:00
b3a7b7ad64
* feat: add agent self-evolution * fix ci * delete unused doc * fix lint * fix evolution review issues
532 lines
15 KiB
Go
532 lines
15 KiB
Go
// PicoClaw - Ultra-lightweight personal AI agent
|
|
// License: MIT
|
|
//
|
|
// Copyright (c) 2026 PicoClaw contributors
|
|
|
|
package config
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
)
|
|
|
|
// buildModelWithProtocol constructs a model string with protocol prefix.
|
|
// If the model already contains a "/" (indicating it has a protocol prefix), it is returned as-is.
|
|
// Otherwise, the protocol prefix is added.
|
|
func buildModelWithProtocol(protocol, model string) string {
|
|
if strings.Contains(model, "/") {
|
|
// Model already has a protocol prefix, return as-is
|
|
return model
|
|
}
|
|
return protocol + "/" + model
|
|
}
|
|
|
|
type legacyDiagnosticConfig struct {
|
|
Version int `json:"version"`
|
|
Isolation IsolationConfig `json:"isolation,omitempty"`
|
|
Agents legacyDiagnosticAgents `json:"agents,omitempty"`
|
|
Session SessionConfig `json:"session,omitempty"`
|
|
Channels map[string]any `json:"channels,omitempty"`
|
|
ChannelList ChannelsConfig `json:"channel_list,omitempty"`
|
|
ModelList []map[string]any `json:"model_list,omitempty"`
|
|
Gateway GatewayConfig `json:"gateway,omitempty"`
|
|
Hooks HooksConfig `json:"hooks,omitempty"`
|
|
Tools ToolsConfig `json:"tools,omitempty"`
|
|
Heartbeat HeartbeatConfig `json:"heartbeat,omitempty"`
|
|
Devices DevicesConfig `json:"devices,omitempty"`
|
|
Voice VoiceConfig `json:"voice,omitempty"`
|
|
Bindings json.RawMessage `json:"bindings,omitempty"`
|
|
Providers json.RawMessage `json:"providers,omitempty"`
|
|
}
|
|
|
|
type legacyDiagnosticAgents struct {
|
|
Defaults legacyDiagnosticAgentDefaults `json:"defaults,omitempty"`
|
|
List []AgentConfig `json:"list,omitempty"`
|
|
Dispatch *DispatchConfig `json:"dispatch,omitempty"`
|
|
}
|
|
|
|
type legacyDiagnosticAgentDefaults struct {
|
|
AgentDefaults
|
|
LegacyModel string `json:"model,omitempty"`
|
|
}
|
|
|
|
func validateLegacyConfigDiagnostics(data []byte) error {
|
|
var cfg legacyDiagnosticConfig
|
|
return decodeJSONWithDiagnostics(data, &cfg, "config.json")
|
|
}
|
|
|
|
func migrateLegacyAgentDefaultsModel(m map[string]any) {
|
|
agents, ok := m["agents"].(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
defaults, ok := agents["defaults"].(map[string]any)
|
|
if !ok {
|
|
return
|
|
}
|
|
model, hasModel := defaults["model"]
|
|
if !hasModel {
|
|
return
|
|
}
|
|
if _, hasModelName := defaults["model_name"]; !hasModelName {
|
|
defaults["model_name"] = model
|
|
}
|
|
delete(defaults, "model")
|
|
}
|
|
|
|
// loadConfigV1 loads a version 1 config (current schema)
|
|
func loadConfig(data []byte) (*Config, error) {
|
|
cfg := DefaultConfig()
|
|
evolutionModeExplicit := configObjectHasField(data, "evolution", "mode")
|
|
evolutionExplicitWithoutMode := configObjectHasTopLevelField(data, "evolution") && !evolutionModeExplicit
|
|
|
|
// Pre-scan the JSON to check how many model_list entries the user provided.
|
|
// Go's JSON decoder reuses existing slice backing-array elements rather than
|
|
// zero-initializing them, so fields absent from the user's JSON (e.g. api_base)
|
|
// would silently inherit values from the DefaultConfig template at the same
|
|
// index position. We only reset cfg.ModelList when the user actually provides
|
|
// entries; when count is 0 we keep DefaultConfig's built-in list as fallback.
|
|
var tmp Config
|
|
if err := decodeJSONWithDiagnostics(data, &tmp, "config.json"); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(tmp.ModelList) > 0 {
|
|
cfg.ModelList = nil
|
|
}
|
|
|
|
if err := decodeJSONWithDiagnostics(data, cfg, "config.json"); err != nil {
|
|
return nil, err
|
|
}
|
|
if evolutionExplicitWithoutMode {
|
|
cfg.Evolution.Mode = ""
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func configObjectHasTopLevelField(data []byte, field string) bool {
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return false
|
|
}
|
|
_, ok := raw[field]
|
|
return ok
|
|
}
|
|
|
|
func configObjectHasField(data []byte, objectField, nestedField string) bool {
|
|
var raw map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
return false
|
|
}
|
|
objectData, ok := raw[objectField]
|
|
if !ok {
|
|
return false
|
|
}
|
|
var object map[string]json.RawMessage
|
|
if err := json.Unmarshal(objectData, &object); err != nil {
|
|
return false
|
|
}
|
|
_, ok = object[nestedField]
|
|
return ok
|
|
}
|
|
|
|
func mergeAPIKeys(apiKey string, apiKeys []string) []string {
|
|
seen := make(map[string]struct{})
|
|
var all []string
|
|
|
|
if k := strings.TrimSpace(apiKey); k != "" {
|
|
if _, exists := seen[k]; !exists {
|
|
seen[k] = struct{}{}
|
|
all = append(all, k)
|
|
}
|
|
}
|
|
|
|
for _, k := range apiKeys {
|
|
if trimmed := strings.TrimSpace(k); trimmed != "" {
|
|
if _, exists := seen[trimmed]; !exists {
|
|
seen[trimmed] = struct{}{}
|
|
all = append(all, trimmed)
|
|
}
|
|
}
|
|
}
|
|
|
|
return all
|
|
}
|
|
|
|
func compareInt(v any, expected int) bool {
|
|
switch val := v.(type) {
|
|
case int:
|
|
return val == expected
|
|
case float64:
|
|
return val == float64(expected)
|
|
case nil:
|
|
return expected == 0
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// migrateV0ToV1 converts a V0 (legacy, no version field) config JSON to V1 format:
|
|
// 1. Migrates legacy providers to model_list
|
|
// 2. Migrates agents.defaults.model → agents.defaults.model_name
|
|
// 3. Sets version to 1
|
|
func migrateV0ToV1(m map[string]any) error {
|
|
if !compareInt(m["version"], 0) {
|
|
return fmt.Errorf("migrateV0ToV1: expected version 0, got %v", m["version"])
|
|
}
|
|
|
|
migrateLegacyAgentDefaultsModel(m)
|
|
|
|
// Migrate legacy providers to model_list if no model_list exists
|
|
if _, hasModelList := m["model_list"]; !hasModelList {
|
|
if providers, hasProviders := m["providers"]; hasProviders {
|
|
if provMap, ok := providers.(map[string]any); ok && !isProvidersMapEmpty(provMap) {
|
|
// Extract user's provider and model from agents.defaults
|
|
userProvider := ""
|
|
userModel := ""
|
|
if agents, ok := m["agents"].(map[string]any); ok {
|
|
if defaults, ok := agents["defaults"].(map[string]any); ok {
|
|
if v, ok := defaults["provider"].(string); ok {
|
|
userProvider = v
|
|
}
|
|
// Check both model_name (new) and model (old) fields
|
|
if v, ok := defaults["model_name"].(string); ok && v != "" {
|
|
userModel = v
|
|
} else if v, ok := defaults["model"].(string); ok && v != "" {
|
|
userModel = v
|
|
}
|
|
}
|
|
}
|
|
|
|
modelListRaw := v0ProvidersMapToModelList(provMap, userProvider, userModel)
|
|
if len(modelListRaw) > 0 {
|
|
m["model_list"] = modelListRaw
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Convert model_list api_key → api_keys
|
|
if modelList, ok := m["model_list"].([]any); ok {
|
|
for _, model := range modelList {
|
|
if mVal, ok := model.(map[string]any); ok {
|
|
if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 {
|
|
mVal["api_keys"] = ss
|
|
delete(mVal, "api_key")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
m["version"] = 1
|
|
|
|
return nil
|
|
}
|
|
|
|
func toUniqueStrings(s any, ss any) []string {
|
|
set := make(map[string]struct{})
|
|
|
|
// process s
|
|
if str, ok := s.(string); ok && str != "" {
|
|
set[str] = struct{}{}
|
|
}
|
|
|
|
// process ss as []any (JSON arrays)
|
|
if slice, ok := ss.([]any); ok {
|
|
for _, item := range slice {
|
|
if str, ok := item.(string); ok && str != "" {
|
|
set[str] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// process ss as []string
|
|
if slice, ok := ss.([]string); ok {
|
|
for _, item := range slice {
|
|
if item != "" {
|
|
set[item] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
|
|
// map to slice
|
|
result := make([]string, 0, len(set))
|
|
for k := range set {
|
|
result = append(result, k)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// migrateV1ToV2 converts a V1 config JSON to V2 format:
|
|
// 1. Migrates legacy "mention_only" to "group_trigger.mention_only"
|
|
// 2. Infers "enabled" field for models
|
|
// 3. Sets version to 2
|
|
func migrateV1ToV2(m map[string]any) error {
|
|
if !compareInt(m["version"], 1) {
|
|
return fmt.Errorf("migrateV1ToV2: expected version 1, got %#v", m["version"])
|
|
}
|
|
|
|
// Migrate channels: move "mention_only" to "group_trigger.mention_only"
|
|
if channels, ok := m["channels"]; ok {
|
|
if chMap, ok := channels.(map[string]any); ok {
|
|
for _, ch := range chMap {
|
|
if chVal, ok := ch.(map[string]any); ok {
|
|
if mentionOnly, hasMention := chVal["mention_only"]; hasMention {
|
|
delete(chVal, "mention_only")
|
|
if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT {
|
|
gt["mention_only"] = mentionOnly
|
|
} else {
|
|
chVal["group_trigger"] = map[string]any{"mention_only": mentionOnly}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Infer "enabled" field for models matching configV1.migrateModelEnabled behavior
|
|
if modelList, ok := m["model_list"].([]any); ok {
|
|
// Convert api_key → api_keys for each model
|
|
for _, model := range modelList {
|
|
if mVal, ok := model.(map[string]any); ok {
|
|
if ss := toUniqueStrings(mVal["api_key"], mVal["api_keys"]); len(ss) > 0 {
|
|
mVal["api_keys"] = ss
|
|
delete(mVal, "api_key")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Infer enabled status
|
|
for _, model := range modelList {
|
|
if mVal, ok := model.(map[string]any); ok {
|
|
// Skip if explicitly set
|
|
if _, hasEnabled := mVal["enabled"]; hasEnabled {
|
|
continue
|
|
}
|
|
// Models with API keys are considered enabled
|
|
if apiKeys, hasAPIKeys := mVal["api_keys"]; hasAPIKeys {
|
|
// Check for []any or []string
|
|
hasKeys := false
|
|
if keys, ok := apiKeys.([]any); ok {
|
|
hasKeys = len(keys) > 0
|
|
} else if keys, ok := apiKeys.([]string); ok {
|
|
hasKeys = len(keys) > 0
|
|
}
|
|
if hasKeys {
|
|
mVal["enabled"] = true
|
|
continue
|
|
}
|
|
}
|
|
// The reserved "local-model" entry is considered enabled
|
|
if mVal["model_name"] == "local-model" {
|
|
mVal["enabled"] = true
|
|
}
|
|
logger.Infof("model: %v", mVal)
|
|
}
|
|
}
|
|
} else {
|
|
logger.Warnf("model_list is not a slice: %#v", m["model_list"])
|
|
}
|
|
|
|
m["version"] = 2
|
|
|
|
return nil
|
|
}
|
|
|
|
// migrateV2ToV3 converts a V2 config JSON to V3 format:
|
|
// 1. Renames "channels" key to "channel_list"
|
|
// 2. Converts flat-format channel entries to nested format (wrapping
|
|
// channel-specific fields in "settings")
|
|
// 3. Sets version to 3
|
|
func migrateV2ToV3(m map[string]any) error {
|
|
if !compareInt(m["version"], 2) {
|
|
return fmt.Errorf("migrateV2ToV3: expected version 2, got %v", m["version"])
|
|
}
|
|
|
|
migrateLegacyAgentDefaultsModel(m)
|
|
delete(m, "bindings")
|
|
|
|
// Rename channels → channel_list
|
|
if channels, ok := m["channels"]; ok {
|
|
delete(m, "channels")
|
|
|
|
// Convert each channel from flat to nested format
|
|
if chMap, ok := channels.(map[string]any); ok {
|
|
for k, ch := range chMap {
|
|
if chVal, ok := ch.(map[string]any); ok {
|
|
chVal["type"] = k
|
|
// If already has "settings" key, leave as-is
|
|
if _, hasSettings := chVal["settings"]; hasSettings {
|
|
continue
|
|
}
|
|
|
|
// Migrate Onebot "group_trigger_prefix" → "group_trigger.prefixes"
|
|
if gtp, hasGTP := chVal["group_trigger_prefix"]; hasGTP {
|
|
if gt, hasGT := chVal["group_trigger"].(map[string]any); hasGT {
|
|
if _, hasPrefixes := gt["prefixes"]; !hasPrefixes {
|
|
gt["prefixes"] = gtp
|
|
}
|
|
} else {
|
|
chVal["group_trigger"] = map[string]any{"prefixes": gtp}
|
|
}
|
|
delete(chVal, "group_trigger_prefix")
|
|
}
|
|
|
|
// Separate channel-specific fields into "settings"
|
|
settings := make(map[string]any)
|
|
for fieldKey, v := range chVal {
|
|
if _, exists := BaseFieldNames[fieldKey]; !exists {
|
|
settings[fieldKey] = v
|
|
delete(chVal, fieldKey)
|
|
}
|
|
}
|
|
if len(settings) > 0 {
|
|
chVal["settings"] = settings
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
m["channel_list"] = channels
|
|
}
|
|
|
|
m["version"] = CurrentVersion
|
|
|
|
return nil
|
|
}
|
|
|
|
func loadConfigMap(path string) (map[string]any, error) {
|
|
var m1, m2 map[string]any
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return m1, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read config: %w", err)
|
|
}
|
|
if err = json.Unmarshal(data, &m1); err != nil {
|
|
return nil, wrapJSONError(data, err, "config.json")
|
|
}
|
|
secPath := securityPath(path)
|
|
data, err = os.ReadFile(secPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return m1, nil
|
|
}
|
|
return nil, fmt.Errorf("failed to read security config: %w", err)
|
|
}
|
|
if err = yaml.Unmarshal(data, &m2); err != nil {
|
|
return nil, fmt.Errorf("failed to parse security config: %w", err)
|
|
}
|
|
if m2["web"] != nil || m2["skills"] != nil {
|
|
m3 := make(map[string]any)
|
|
if m2["web"] != nil {
|
|
m3["web"] = m2["web"]
|
|
delete(m2, "web")
|
|
}
|
|
if m2["skills"] != nil {
|
|
m3["skills"] = m2["skills"]
|
|
delete(m2, "skills")
|
|
if m, ok := m3["skills"].(map[string]any); ok {
|
|
if m["clawhub"] != nil {
|
|
m["registries"] = map[string]any{"clawhub": m["clawhub"]}
|
|
delete(m, "clawhub")
|
|
}
|
|
if gh, ok := m["github"].(map[string]any); ok {
|
|
registries, _ := m["registries"].(map[string]any)
|
|
if registries == nil {
|
|
registries = map[string]any{}
|
|
}
|
|
githubRegistry := map[string]any{}
|
|
for k, v := range gh {
|
|
githubRegistry[k] = v
|
|
}
|
|
if token, ok := githubRegistry["token"]; ok {
|
|
githubRegistry["auth_token"] = token
|
|
}
|
|
registries["github"] = githubRegistry
|
|
m["registries"] = registries
|
|
}
|
|
}
|
|
}
|
|
m2["tools"] = m3
|
|
}
|
|
|
|
// Handle model_list merging specially: m1 has array format, m2 has map format
|
|
if mainML, hasMainML := m1["model_list"]; hasMainML {
|
|
if secML, hasSecML := m2["model_list"]; hasSecML {
|
|
if secMap, ok := secML.(map[string]any); ok {
|
|
// JSON unmarshals arrays as []any, convert to []map[string]any
|
|
var mainArr []any
|
|
if rawArr, ok := mainML.([]any); ok {
|
|
mainArr = make([]any, 0, len(rawArr))
|
|
for _, item := range rawArr {
|
|
if mVal, ok := item.(map[string]any); ok {
|
|
mainArr = append(mainArr, mVal)
|
|
}
|
|
}
|
|
}
|
|
if len(mainArr) > 0 {
|
|
// Merge array-style with map-style in-place
|
|
err = mergeModelListsWithMap(mainArr, secMap)
|
|
if err != nil {
|
|
logger.Errorf("mergeModelListsWithMap error: %v", err)
|
|
return nil, err
|
|
}
|
|
m1["model_list"] = mainArr
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Remove model_list from m2 so mergeMap doesn't override the array with map
|
|
delete(m2, "model_list")
|
|
|
|
m := mergeMap(m1, m2)
|
|
return m, nil
|
|
}
|
|
|
|
// mergeModelListsWithMap merges array-style model_list with map-style security model_list.
|
|
// It generates indexed keys from model_name (like toNameIndex) and uses them
|
|
// to look up security entries, falling back to ModelName if the indexed key doesn't exist.
|
|
func mergeModelListsWithMap(mainML []any, secML map[string]any) error {
|
|
// Build indexed keys like toNameIndex does
|
|
indexedKeys := make(map[string]int)
|
|
countMap := make(map[string]int)
|
|
for i, m := range mainML {
|
|
if mVal, ok := m.(map[string]any); ok {
|
|
if name, hasName := mVal["model_name"]; hasName {
|
|
nameStr := name.(string)
|
|
index := countMap[nameStr]
|
|
indexedKeys[fmt.Sprintf("%s:%d", nameStr, index)] = i
|
|
if _, ok := indexedKeys[nameStr]; !ok {
|
|
indexedKeys[nameStr] = i
|
|
}
|
|
countMap[nameStr]++
|
|
} else {
|
|
return fmt.Errorf("model_name is required: %#v", mVal)
|
|
}
|
|
}
|
|
}
|
|
|
|
for k, v := range secML {
|
|
if i, ok := indexedKeys[k]; ok {
|
|
if vv, ok := v.(map[string]any); ok {
|
|
if mVal, ok := mainML[i].(map[string]any); ok {
|
|
mVal["api_keys"] = vv["api_keys"]
|
|
}
|
|
}
|
|
} else {
|
|
logger.Warnf("model_name not found in main config: %s", k)
|
|
}
|
|
delete(secML, k)
|
|
}
|
|
|
|
return nil
|
|
}
|