169 lines
4.7 KiB
Go
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()
|
|
}
|