Files
magpie/internal/library/library.go
T

169 lines
4.7 KiB
Go

// Package library keeps what every agent should know, once: the
// instructions each reads before a conversation, the MCP servers it can
// call and the skills it can load. magpie writes them into each agent's own
// files in that agent's own format, and takes back only what it wrote —
// whatever else is in those files stays as it was.
package library
import (
"encoding/json"
"fmt"
"path/filepath"
"regexp"
"slices"
"sort"
"sync"
"github.com/yetone/magpie/internal/edit"
"github.com/yetone/magpie/internal/settings"
)
// Library is library.json: what magpie keeps, and which agents get it.
type Library struct {
Instructions Instructions `json:"instructions"`
MCP []*Server `json:"mcp"`
Skills []*Skill `json:"skills"`
// Applied is what magpie wrote into each agent, so that what it takes
// away is only ever its own.
Applied map[string]*Applied `json:"applied,omitempty"`
// Icons are the icons of the servers added from the market, by what
// each runs, for the page to show them by
Icons map[string]string `json:"icons,omitempty"`
// kept is where a change put what it kept aside before the sync, for
// the sync to keep the agents' files beside it
kept *backups
}
// Instructions are one shared text, and for each agent whether it gets it
// and what it gets besides (kept in files beside library.json). The shared
// text is one of several sets kept to switch between (#106), the one on.
type Instructions struct {
Agents []string `json:"agents"` // the agents the shared text is written to
// Sets are the named texts to pick the shared one from; with none kept
// there is the one magpie always had, "default"
Sets []InstrSet `json:"sets,omitempty"`
Active string `json:"active,omitempty"` // the set agents get; "" is "default"
}
// InstrSet is one set of shared instructions: its text is in its own file.
type InstrSet struct {
ID string `json:"id"`
Name string `json:"name"` // "" for the first, which the page calls Default
}
// Applied is what magpie last wrote into one agent.
type Applied struct {
Instructions bool `json:"instructions,omitempty"`
InstrHash string `json:"instrHash,omitempty"` // of the part last written
MCP []string `json:"mcp,omitempty"`
Skills []string `json:"skills,omitempty"`
}
var mu sync.Mutex // one change at a time, from the page or the CLI's process
// Dir is where the library's own files are: the instructions and the skills.
func Dir() string { return filepath.Join(settings.Dir(), "library") }
func path() string { return filepath.Join(settings.Dir(), "library.json") }
func load() (*Library, error) {
l := &Library{}
b, err := edit.Read(path())
if err != nil {
return nil, err
}
if len(b) > 0 {
if err := json.Unmarshal(b, l); err != nil {
return nil, fmt.Errorf("%s: %w", path(), err)
}
}
if l.Applied == nil {
l.Applied = map[string]*Applied{}
}
return l, nil
}
func (l *Library) save() error {
sort.Slice(l.MCP, func(i, j int) bool { return l.MCP[i].Name < l.MCP[j].Name })
sort.Slice(l.Skills, func(i, j int) bool { return l.Skills[i].Name < l.Skills[j].Name })
for _, a := range l.Applied {
sort.Strings(a.MCP)
sort.Strings(a.Skills)
}
b, err := json.MarshalIndent(l, "", " ")
if err != nil {
return err
}
return edit.WriteAtomic(path(), append(b, '\n'))
}
func (l *Library) applied(agent string) *Applied {
a := l.Applied[agent]
if a == nil {
a = &Applied{}
l.Applied[agent] = a
}
return a
}
func (l *Library) server(name string) *Server {
for _, s := range l.MCP {
if s.Name == name {
return s
}
}
return nil
}
func (l *Library) skill(name string) *Skill {
for _, s := range l.Skills {
if s.Name == name {
return s
}
}
return nil
}
// A name is what a server or a skill is called in every agent's file, and
// a skill's folder: it has to be a bare key in TOML and a plain file name.
var nameRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$`)
func checkName(kind, name string) error {
if !nameRe.MatchString(name) {
return fmt.Errorf("%s name %q: use letters, digits, - and _ only", kind, name)
}
return nil
}
// set turns id on or off in a list of agent ids, which stays sorted.
func set(list []string, id string, on bool) []string {
i := slices.Index(list, id)
switch {
case on && i < 0:
list = append(list, id)
sort.Strings(list)
case !on && i >= 0:
list = slices.Delete(list, i, i+1)
}
return list
}
// change runs f on the library under the lock, saves it, and writes what
// changed into the agents.
func change(f func(l *Library) error) (*Result, error) {
mu.Lock()
defer mu.Unlock()
l, err := load()
if err != nil {
return nil, err
}
if err := f(l); err != nil {
return nil, err
}
if err := l.save(); err != nil {
return nil, err
}
res := l.sync()
return res, l.save()
}