internal/learning/store.go
Rama main · réplica de lectura del repositorio original.
package learning
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/yeomyeonggeori/blueclaw/internal/skill"
)
const DefaultActiveLimit = 20
const maximumSkillInstructionBytes = 8 * 1024
const maximumSkillInstructionLines = 300
const maximumCandidateSkills = 20
const maximumRetainedRevisions = 100
type Skill struct {
ID string `json:"id"`
Version int `json:"version"`
Audience string `json:"audience"`
Description string `json:"description"`
Instruction string `json:"instruction"`
EvidenceIDs []string `json:"evidenceIDs"`
Reason string `json:"reason,omitempty"`
Verification string `json:"verification"`
Status string `json:"status"`
Protected bool `json:"protected"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
RetiredAt time.Time `json:"retiredAt,omitempty"`
}
type Store struct {
mutex sync.Mutex
path string
activeLimit int
enabled bool
skills map[string][]Skill
}
type Settings struct {
Enabled bool `json:"enabled"`
ActiveLimit int `json:"activeLimit"`
}
type StoreDecision struct {
Action string `json:"action"`
Skill Skill `json:"skill"`
ID string `json:"id"`
ReplaceID string `json:"replaceID,omitempty"`
ExpectedVersion int `json:"expectedVersion"`
}
func Open(path string, activeLimit int) (*Store, error) {
if activeLimit <= 0 {
activeLimit = DefaultActiveLimit
}
store := &Store{path: path, activeLimit: activeLimit, skills: map[string][]Skill{}}
document, errorValue := os.ReadFile(path)
if os.IsNotExist(errorValue) {
document = nil
errorValue = nil
}
if errorValue != nil {
return nil, errorValue
}
if len(document) > 0 {
if errorValue := json.Unmarshal(document, &store.skills); errorValue != nil {
return nil, errorValue
}
for _, versions := range store.skills {
for _, storedSkill := range versions {
if errorValue := validateSkillInstruction(storedSkill); errorValue != nil {
return nil, errorValue
}
}
}
}
if settingsDocument, settingsError := os.ReadFile(path + ".settings"); settingsError == nil {
var settings Settings
if json.Unmarshal(settingsDocument, &settings) == nil {
store.enabled = settings.Enabled
if settings.ActiveLimit > 0 {
store.activeLimit = settings.ActiveLimit
}
}
}
return store, nil
}
func (store *Store) List(audience string, includeRetired bool) []Skill {
store.mutex.Lock()
defer store.mutex.Unlock()
result := []Skill{}
for _, versions := range store.skills {
if len(versions) == 0 {
continue
}
skill := versions[len(versions)-1]
if skill.Audience == audience && (includeRetired || skill.Status == "active") {
result = append(result, skill)
}
}
sort.Slice(result, func(first, second int) bool { return result[first].UpdatedAt.After(result[second].UpdatedAt) })
return result
}
func (store *Store) ListEveryAudience() []Skill {
store.mutex.Lock()
defer store.mutex.Unlock()
result := []Skill{}
for _, versions := range store.skills {
if len(versions) > 0 {
result = append(result, cloneSkill(versions[len(versions)-1]))
}
}
sort.Slice(result, func(first, second int) bool { return result[first].UpdatedAt.After(result[second].UpdatedAt) })
return result
}
func (store *Store) Get(id string, audience string, includeHistory bool) ([]Skill, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
versions := store.skills[strings.TrimSpace(id)]
if len(versions) == 0 {
return nil, errors.New("learned skill not found")
}
if versions[len(versions)-1].Audience != audience {
return nil, errors.New("learned skill not found")
}
if includeHistory {
return cloneSkillSlice(versions), nil
}
return []Skill{cloneSkill(versions[len(versions)-1])}, nil
}
func (store *Store) Settings() Settings {
store.mutex.Lock()
defer store.mutex.Unlock()
return Settings{Enabled: store.enabled, ActiveLimit: store.activeLimit}
}
func (store *Store) ActiveCount() int {
store.mutex.Lock()
defer store.mutex.Unlock()
return store.activeCount()
}
func (store *Store) UpdateSettings(settings Settings) error {
store.mutex.Lock()
defer store.mutex.Unlock()
if settings.ActiveLimit <= 0 {
return errors.New("active limit must be positive")
}
if errorValue := writePrivateJSON(store.path+".settings", settings); errorValue != nil {
return errorValue
}
store.enabled, store.activeLimit = settings.Enabled, settings.ActiveLimit
return nil
}
func (store *Store) Activate(id string, expectedVersion int) (Skill, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
versions := store.skills[id]
if len(versions) == 0 {
return Skill{}, errors.New("learned skill not found")
}
latest := versions[len(versions)-1]
if latest.Version != expectedVersion || latest.Status != "candidate" {
return Skill{}, errors.New("learned skill revision is stale")
}
if store.activeCount() >= store.activeLimit {
return Skill{}, errors.New("active learned skill limit reached")
}
copySkills := cloneSkills(store.skills)
copySkills[id][len(versions)-1].Status = "active"
copySkills[id][len(versions)-1].UpdatedAt = time.Now().UTC()
if errorValue := store.persistSkills(copySkills); errorValue != nil {
return Skill{}, errorValue
}
store.skills = copySkills
return copySkills[id][len(versions)-1], nil
}
func (store *Store) ApplyDecision(decision StoreDecision) (Skill, error) {
switch decision.Action {
case "create", "revise":
return store.Put(decision.Skill)
case "activate":
return store.Activate(decision.ID, decision.ExpectedVersion)
case "replace":
return store.replace(decision)
case "retire":
return store.retireDecision(decision)
case "restore":
return store.Restore(decision.ID)
default:
return Skill{}, errors.New("unsupported learning decision")
}
}
func (store *Store) retireDecision(decision StoreDecision) (Skill, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
versions := store.skills[decision.ID]
if len(versions) == 0 {
return Skill{}, errors.New("active learned skill not found")
}
latest := versions[len(versions)-1]
if latest.Audience != decision.Skill.Audience {
return Skill{}, errors.New("learned skill not found")
}
if decision.ExpectedVersion == 0 || latest.Version != decision.ExpectedVersion {
return Skill{}, errors.New("learned skill revision is stale")
}
if latest.Status != "active" || latest.Protected {
return Skill{}, errors.New("active learned skill cannot be retired")
}
copySkills := cloneSkills(store.skills)
copySkills[decision.ID][len(versions)-1].Status = "retired"
copySkills[decision.ID][len(versions)-1].RetiredAt = time.Now().UTC()
copySkills[decision.ID][len(versions)-1].UpdatedAt = time.Now().UTC()
if errorValue := store.persistSkills(copySkills); errorValue != nil {
return Skill{}, errorValue
}
store.skills = copySkills
return cloneSkill(copySkills[decision.ID][len(versions)-1]), nil
}
func (store *Store) replace(decision StoreDecision) (Skill, error) {
if errorValue := validateSkillInstruction(decision.Skill); errorValue != nil {
return Skill{}, errorValue
}
store.mutex.Lock()
defer store.mutex.Unlock()
versions := store.skills[decision.ReplaceID]
if len(versions) == 0 || versions[len(versions)-1].Protected {
return Skill{}, errors.New("replace target is unavailable")
}
previous := versions[len(versions)-1]
if decision.ExpectedVersion == 0 || previous.Version != decision.ExpectedVersion {
return Skill{}, errors.New("learned skill revision is stale")
}
if previous.Audience != decision.Skill.Audience {
return Skill{}, errors.New("learned skill audience cannot change")
}
if decision.Skill.ID == "" || decision.Skill.Audience == "" || decision.Skill.Instruction == "" {
return Skill{}, errors.New("replacement skill is incomplete")
}
if decision.Skill.ID == decision.ReplaceID {
return Skill{}, errors.New("replacement must use a new skill ID")
}
if len(store.skills[decision.Skill.ID]) != 0 {
return Skill{}, errors.New("replacement skill ID already exists")
}
if decision.Skill.Status == "" {
decision.Skill.Status = "candidate"
}
if decision.Skill.Status != "candidate" && decision.Skill.Status != "active" {
return Skill{}, errors.New("learned skill status is invalid")
}
if len([]byte(decision.Skill.Instruction)) > maximumSkillInstructionBytes || len(strings.Split(decision.Skill.Instruction, "\n")) > maximumSkillInstructionLines {
return Skill{}, errors.New("learned skill instruction exceeds size limits")
}
activeCount := store.activeCount()
if previous.Status == "active" {
activeCount--
}
if decision.Skill.Status == "active" && activeCount >= store.activeLimit {
return Skill{}, errors.New("active learned skill limit reached")
}
if decision.Skill.Status == "candidate" && store.candidateCount() >= maximumCandidateSkills && versions[len(versions)-1].Status != "candidate" {
return Skill{}, errors.New("learned skill candidate limit reached")
}
if store.revisionCount() >= maximumRetainedRevisions {
return Skill{}, errors.New("learned skill history limit reached")
}
decision.Skill.Version = 1
decision.Skill.CreatedAt = time.Now().UTC()
decision.Skill.UpdatedAt = decision.Skill.CreatedAt
copySkills := cloneSkills(store.skills)
copySkills[decision.ReplaceID][len(versions)-1].Status = "retired"
copySkills[decision.ReplaceID][len(versions)-1].RetiredAt = time.Now().UTC()
copySkills[decision.Skill.ID] = append(copySkills[decision.Skill.ID], decision.Skill)
if errorValue := store.persistSkills(copySkills); errorValue != nil {
return Skill{}, errorValue
}
store.skills = copySkills
return decision.Skill, nil
}
func cloneSkill(skill Skill) Skill {
skill.EvidenceIDs = append([]string{}, skill.EvidenceIDs...)
return skill
}
func cloneSkillSlice(skills []Skill) []Skill {
clone := make([]Skill, len(skills))
for index, skill := range skills {
clone[index] = cloneSkill(skill)
}
return clone
}
func (store *Store) Put(skill Skill) (Skill, error) {
if errorValue := validateSkillInstruction(skill); errorValue != nil {
return Skill{}, errorValue
}
store.mutex.Lock()
defer store.mutex.Unlock()
if strings.TrimSpace(skill.ID) == "" || strings.TrimSpace(skill.Audience) == "" || strings.TrimSpace(skill.Instruction) == "" {
return Skill{}, errors.New("skill id, audience, and instruction are required")
}
if len([]byte(skill.Instruction)) > maximumSkillInstructionBytes || len(strings.Split(skill.Instruction, "\n")) > maximumSkillInstructionLines {
return Skill{}, errors.New("learned skill instruction exceeds size limits")
}
versions := store.skills[skill.ID]
if len(versions) > 0 {
latest := versions[len(versions)-1]
if skill.Version == 0 || skill.Version != latest.Version {
return Skill{}, errors.New("learned skill revision is stale")
}
if latest.Protected {
return Skill{}, errors.New("protected learned skill cannot be replaced")
}
skill.Version = latest.Version + 1
skill.CreatedAt = latest.CreatedAt
if skill.Audience != latest.Audience {
return Skill{}, errors.New("learned skill audience cannot change")
}
if latest.Protected {
skill.Protected = true
}
}
if skill.Version == 0 {
skill.Version = 1
}
if skill.Status == "" {
skill.Status = "candidate"
}
if skill.Status != "candidate" && skill.Status != "active" {
return Skill{}, errors.New("learned skill status is invalid")
}
if skill.Status == "active" && store.activeCount() >= store.activeLimit && (len(versions) == 0 || versions[len(versions)-1].Status != "active") {
return Skill{}, errors.New("active learned skill limit reached")
}
if skill.Status == "candidate" && store.candidateCount() >= maximumCandidateSkills && (len(versions) == 0 || versions[len(versions)-1].Status != "candidate") {
return Skill{}, errors.New("learned skill candidate limit reached")
}
if store.revisionCount() >= maximumRetainedRevisions && len(versions) == 0 {
return Skill{}, errors.New("learned skill history limit reached")
}
now := time.Now().UTC()
if skill.CreatedAt.IsZero() {
skill.CreatedAt = now
}
skill.UpdatedAt = now
copySkills := cloneSkills(store.skills)
copySkills[skill.ID] = append(copySkills[skill.ID], skill)
if errorValue := store.persistSkills(copySkills); errorValue != nil {
return Skill{}, errorValue
}
store.skills = copySkills
return skill, nil
}
func validateSkillInstruction(candidate Skill) error {
parsed, errorValue := skill.ParseDocument(candidate.Instruction)
if errorValue != nil {
return errors.New("learned skill instruction is not a valid SKILL.md document")
}
if strings.TrimSpace(parsed.Name) != "" && strings.TrimSpace(parsed.Name) != strings.TrimSpace(candidate.ID) {
return errors.New("learned skill document name does not match its ID")
}
if strings.TrimSpace(candidate.Description) == "" && strings.TrimSpace(parsed.Description) == "" {
return errors.New("learned skill description is required")
}
return nil
}
func (store *Store) SetProtected(id string, protected bool) error {
store.mutex.Lock()
defer store.mutex.Unlock()
versions := store.skills[id]
if len(versions) == 0 {
return errors.New("learned skill not found")
}
copySkills := cloneSkills(store.skills)
copySkills[id][len(versions)-1].Protected = protected
copySkills[id][len(versions)-1].UpdatedAt = time.Now().UTC()
if errorValue := store.persistSkills(copySkills); errorValue != nil {
return errorValue
}
store.skills = copySkills
return nil
}
func (store *Store) Retire(id string) error { return store.changeStatus(id, "retired") }
func (store *Store) Restore(id string) (Skill, error) {
store.mutex.Lock()
defer store.mutex.Unlock()
versions := store.skills[id]
if len(versions) == 0 || versions[len(versions)-1].Status != "retired" {
return Skill{}, errors.New("retired learned skill not found")
}
if store.activeCount() >= store.activeLimit {
return Skill{}, errors.New("active learned skill limit reached")
}
copySkills := cloneSkills(store.skills)
copySkills[id][len(versions)-1].Status = "active"
copySkills[id][len(versions)-1].RetiredAt = time.Time{}
copySkills[id][len(versions)-1].UpdatedAt = time.Now().UTC()
if errorValue := store.persistSkills(copySkills); errorValue != nil {
return Skill{}, errorValue
}
store.skills = copySkills
return copySkills[id][len(versions)-1], nil
}
func (store *Store) changeStatus(id string, status string) error {
store.mutex.Lock()
defer store.mutex.Unlock()
versions := store.skills[id]
if len(versions) == 0 || versions[len(versions)-1].Status != "active" {
return errors.New("active learned skill not found")
}
if versions[len(versions)-1].Protected {
return errors.New("protected learned skill cannot be retired")
}
copySkills := cloneSkills(store.skills)
copySkills[id][len(versions)-1].Status = status
copySkills[id][len(versions)-1].RetiredAt = time.Now().UTC()
copySkills[id][len(versions)-1].UpdatedAt = time.Now().UTC()
if errorValue := store.persistSkills(copySkills); errorValue != nil {
return errorValue
}
store.skills = copySkills
return nil
}
func (store *Store) activeCount() int {
count := 0
for _, versions := range store.skills {
if len(versions) > 0 && versions[len(versions)-1].Status == "active" {
count++
}
}
return count
}
func (store *Store) candidateCount() int {
count := 0
for _, versions := range store.skills {
if len(versions) > 0 && versions[len(versions)-1].Status == "candidate" {
count++
}
}
return count
}
func (store *Store) revisionCount() int {
count := 0
for _, versions := range store.skills {
count += len(versions)
}
return count
}
func (store *Store) persist() error {
return store.persistSkills(store.skills)
}
func cloneSkills(source map[string][]Skill) map[string][]Skill {
clone := map[string][]Skill{}
for id, versions := range source {
clone[id] = append([]Skill{}, versions...)
}
return clone
}
func (store *Store) persistSkills(skills map[string][]Skill) error {
document, errorValue := json.MarshalIndent(skills, "", " ")
if errorValue != nil {
return errorValue
}
if errorValue := os.MkdirAll(filepath.Dir(store.path), 0o750); errorValue != nil {
return errorValue
}
temporary, errorValue := os.CreateTemp(filepath.Dir(store.path), ".learned-skills-*.json")
if errorValue != nil {
return errorValue
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if _, errorValue := temporary.Write(append(document, '\n')); errorValue != nil {
temporary.Close()
return errorValue
}
if errorValue := temporary.Close(); errorValue != nil {
return errorValue
}
return os.Rename(temporaryPath, store.path)
}