package learning import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "sync" "time" "github.com/yeomyeonggeori/blueclaw/internal/persona" ) const maxReviewEvidenceBytes = 48 * 1024 type SoulRevision = persona.SoulRevision type ReviewRecord struct { ID string `json:"id"` Audience string `json:"audience"` Decision Decision `json:"decision"` Traces []ReviewTrace `json:"traces,omitempty"` Error string `json:"error,omitempty"` } type reflectionState struct { Pending []Experience `json:"pending"` InFlight []Experience `json:"inFlight,omitempty"` ReviewedIDs []string `json:"reviewedIDs"` LastObserved time.Time `json:"lastObserved"` LastReview time.Time `json:"lastReview"` ReviewDay string `json:"reviewDay"` DailyReviews int `json:"dailyReviews"` } type Coordinator struct { mutex sync.Mutex store *Store reviewer Reviewer root string state reflectionState tools func() []string reviewing bool activeTasks int Report func(error) } func (coordinator *Coordinator) UseAvailableTools(tools func() []string) { coordinator.mutex.Lock() defer coordinator.mutex.Unlock() coordinator.tools = tools } func (coordinator *Coordinator) IsEnabled() bool { coordinator.mutex.Lock() defer coordinator.mutex.Unlock() return coordinator.store != nil && coordinator.store.Settings().Enabled } func (coordinator *Coordinator) BeginTask() func() { coordinator.mutex.Lock() coordinator.activeTasks++ coordinator.mutex.Unlock() return func() { coordinator.mutex.Lock() coordinator.activeTasks-- coordinator.state.LastObserved = time.Now().UTC() coordinator.mutex.Unlock() } } func NewCoordinator(root string, store *Store, reviewer Reviewer, tools func() []string) (*Coordinator, error) { coordinator := &Coordinator{root: root, store: store, reviewer: reviewer, tools: tools} document, errorValue := os.ReadFile(coordinator.statePath()) if os.IsNotExist(errorValue) { return coordinator, nil } if errorValue != nil { return nil, errorValue } if errorValue := json.Unmarshal(document, &coordinator.state); errorValue != nil { return nil, errorValue } if len(coordinator.state.InFlight) > 0 { state := coordinator.state state.Pending = append(append([]Experience{}, state.InFlight...), state.Pending...) state.InFlight = nil if errorValue := coordinator.saveState(state); errorValue != nil { return nil, errorValue } } return coordinator, nil } func (coordinator *Coordinator) Observe(experience Experience) error { coordinator.mutex.Lock() defer coordinator.mutex.Unlock() if experience.TaskID == "" || experience.Audience == "" || !json.Valid(experience.Outcome) { return errors.New("learning experience is incomplete") } for _, id := range coordinator.state.ReviewedIDs { if id == experience.TaskID { return nil } } for _, pending := range coordinator.state.Pending { if pending.TaskID == experience.TaskID { return nil } } if len(coordinator.state.Pending) >= 100 || len(experience.Request)+len(experience.Outcome) > 32768 { return errors.New("learning evidence budget reached") } state := coordinator.state state.Pending = append(append([]Experience{}, state.Pending...), experience) state.LastObserved = time.Now().UTC() return coordinator.saveState(state) } func (coordinator *Coordinator) Run(ctx context.Context) { ticker := time.NewTicker(time.Minute) defer ticker.Stop() for { select { case <-ctx.Done(): return case now := <-ticker.C: if errorValue := coordinator.ReviewPending(ctx, now); errorValue != nil && coordinator.Report != nil { coordinator.Report(errorValue) } } } } func (coordinator *Coordinator) ReviewPending(ctx context.Context, now time.Time) error { batch, errorValue := coordinator.claimBatch(now) if errorValue != nil || len(batch) == 0 { return errorValue } defer func() { coordinator.mutex.Lock(); coordinator.reviewing = false; coordinator.mutex.Unlock() }() soul, errorValue := persona.ReadSoulDocument(coordinator.root) if os.IsNotExist(errorValue) { soul = []byte(`{"schemaVersion":1}`) } else if errorValue != nil { return coordinator.requeuePreparationFailure(batch, errorValue) } if _, errorValue := persona.ParseSoul(soul); errorValue != nil { return coordinator.requeuePreparationFailure(batch, errorValue) } input := ReviewInput{Experience: batch, Soul: soul, Skills: coordinator.store.List(batch[0].Audience, false), ActiveLimit: coordinator.store.Settings().ActiveLimit} if coordinator.tools != nil { input.AvailableTools = coordinator.tools() } for _, experience := range batch { input.AvailableTools = append(input.AvailableTools, experience.Tools...) } reviewContext, cancel := context.WithTimeout(ctx, 2*time.Minute) defer cancel() decision, traces, reviewError := coordinator.reviewer.Review(reviewContext, input) record := ReviewRecord{ID: now.UTC().Format("20060102T150405.000000000"), Audience: batch[0].Audience, Decision: decision, Traces: traces} if reviewError == nil { coordinator.mutex.Lock() learningEnabled := coordinator.store != nil && coordinator.store.Settings().Enabled foregroundActive := coordinator.activeTasks > 0 if !learningEnabled || foregroundActive { reviewError = errors.New("learning commit cancelled by runtime state") } else { reviewError = coordinator.applyDecisionLocked(decision, input) } coordinator.mutex.Unlock() } if reviewError != nil { record.Error = reviewError.Error() _ = coordinator.requeueBatch(batch) } if errorValue := writePrivateJSON(filepath.Join(coordinator.directory(), "reviews", record.ID+".json"), record); errorValue != nil { return errorValue } if reviewError == nil { if errorValue := coordinator.completeBatch(batch); errorValue != nil { return errorValue } } return reviewError } func (coordinator *Coordinator) requeuePreparationFailure(batch []Experience, reviewError error) error { if requeueError := coordinator.requeueBatch(batch); requeueError != nil { return fmt.Errorf("%w; requeue failed: %v", reviewError, requeueError) } return reviewError } func (coordinator *Coordinator) requeueBatch(batch []Experience) error { coordinator.mutex.Lock() defer coordinator.mutex.Unlock() state := coordinator.state state.Pending = append(append([]Experience{}, batch...), state.Pending...) state.InFlight = nil return coordinator.saveState(state) } func (coordinator *Coordinator) completeBatch(batch []Experience) error { coordinator.mutex.Lock() defer coordinator.mutex.Unlock() state := coordinator.state state.InFlight = nil state.ReviewedIDs = append([]string{}, state.ReviewedIDs...) for _, experience := range batch { state.ReviewedIDs = append(state.ReviewedIDs, experience.TaskID) } if len(state.ReviewedIDs) > 2000 { state.ReviewedIDs = state.ReviewedIDs[len(state.ReviewedIDs)-2000:] } return coordinator.saveState(state) } func (coordinator *Coordinator) claimBatch(now time.Time) ([]Experience, error) { coordinator.mutex.Lock() defer coordinator.mutex.Unlock() if coordinator.store == nil || !coordinator.store.Settings().Enabled { return nil, nil } if len(coordinator.state.InFlight) > 0 { return nil, nil } if coordinator.activeTasks > 0 || coordinator.reviewing || len(coordinator.state.Pending) == 0 { return nil, nil } if now.Sub(coordinator.state.LastObserved) < 5*time.Minute || now.Sub(coordinator.state.LastReview) < time.Hour { return nil, nil } state := coordinator.state day := now.UTC().Format("2006-01-02") if state.ReviewDay != day { state.ReviewDay = day state.DailyReviews = 0 } if state.DailyReviews >= 3 { return nil, nil } audience := state.Pending[0].Audience batch := []Experience{} remaining := []Experience{} evidenceBytes := 0 for _, experience := range state.Pending { serializedExperience, errorValue := json.Marshal(experience) if errorValue != nil { return nil, errorValue } if experience.Audience == audience && len(batch) < 20 && evidenceBytes+len(serializedExperience) <= maxReviewEvidenceBytes { batch = append(batch, experience) evidenceBytes += len(serializedExperience) } else { remaining = append(remaining, experience) } } if len(batch) == 0 { return nil, nil } state.Pending = remaining state.InFlight = append([]Experience{}, batch...) state.LastReview = now state.DailyReviews++ if errorValue := coordinator.saveState(state); errorValue != nil { return nil, errorValue } coordinator.reviewing = true return batch, nil } func (coordinator *Coordinator) applyDecisionLocked(decision Decision, input ReviewInput) error { if decision.Action == "keep" { return nil } if decision.Action == "soul" { return coordinator.applySoulLocked(decision, input.Soul) } skill := Skill{ID: decision.SkillID, Version: decision.ExpectedVersion, Audience: input.Experience[0].Audience, Description: decision.Description, Instruction: decision.Instruction, EvidenceIDs: decision.EvidenceIDs, Reason: decision.Reason, Verification: "evidence-reviewed", Status: "active"} expectedVersion := decision.ExpectedVersion if decision.Action == "replace" { expectedVersion = decision.ReplaceVersion } _, errorValue := coordinator.store.ApplyDecision(StoreDecision{Action: decision.Action, Skill: skill, ID: decision.SkillID, ReplaceID: decision.ReplaceID, ExpectedVersion: expectedVersion}) return errorValue } func (coordinator *Coordinator) applySoulLocked(decision Decision, expected json.RawMessage) error { document, errorValue := persona.ParseSoul([]byte(decision.SoulDocument)) if errorValue != nil { return errorValue } canonical, errorValue := persona.CanonicalSoul(document) if errorValue != nil { return errorValue } history, errorValue := persona.ReadSoulHistory(coordinator.root) if errorValue != nil { return errorValue } current := history[len(history)-1] if string(current.Document) != string(expected) { return errors.New("soul changed during reflection") } _, errorValue = persona.AppendSoulRevision(coordinator.root, current.Version, canonical, decision.Reason, decision.EvidenceIDs, "reflection") return errorValue } func (coordinator *Coordinator) CurrentSoul(context.Context) (SoulRevision, error) { history, errorValue := persona.ReadSoulHistory(coordinator.root) if errorValue != nil { return SoulRevision{}, errorValue } return history[len(history)-1], nil } func (coordinator *Coordinator) SoulHistory(context.Context) ([]SoulRevision, error) { return persona.ReadSoulHistory(coordinator.root) } func (coordinator *Coordinator) directory() string { return filepath.Join(coordinator.root, ".blueclaw", "state", "learning") } func (coordinator *Coordinator) statePath() string { return filepath.Join(coordinator.directory(), "reflection.json") } func (coordinator *Coordinator) saveState(state reflectionState) error { if errorValue := writePrivateJSON(coordinator.statePath(), state); errorValue != nil { return errorValue } coordinator.state = state return nil } func writePrivateJSON(path string, value interface{}) error { document, errorValue := json.Marshal(value) if errorValue != nil { return errorValue } if errorValue := os.MkdirAll(filepath.Dir(path), 0700); errorValue != nil { return errorValue } file, errorValue := os.CreateTemp(filepath.Dir(path), ".learning-*") if errorValue != nil { return errorValue } defer os.Remove(file.Name()) if _, errorValue := file.Write(document); errorValue != nil { file.Close() return errorValue } if errorValue := file.Sync(); errorValue != nil { file.Close() return errorValue } if errorValue := file.Close(); errorValue != nil { return errorValue } return os.Rename(file.Name(), path) } type Overview struct { Settings Settings `json:"settings"` Skills []Skill `json:"skills"` SoulHistory []SoulRevision `json:"soulHistory"` Reviews []ReviewRecord `json:"reviews"` PendingCount int `json:"pendingCount"` ReviewedCount int `json:"reviewedCount"` LastReview time.Time `json:"lastReview"` LastObservedTask time.Time `json:"lastObservedTask"` } func (coordinator *Coordinator) Overview() (Overview, error) { soulHistory, errorValue := persona.ReadSoulHistory(coordinator.root) if errorValue != nil { return Overview{}, errorValue } reviews, errorValue := coordinator.readReviews() if errorValue != nil { return Overview{}, errorValue } coordinator.mutex.Lock() state := coordinator.state coordinator.mutex.Unlock() return Overview{ Settings: coordinator.store.Settings(), Skills: coordinator.store.ListEveryAudience(), SoulHistory: soulHistory, Reviews: reviews, PendingCount: len(state.Pending) + len(state.InFlight), ReviewedCount: len(state.ReviewedIDs), LastReview: state.LastReview, LastObservedTask: state.LastObserved, }, nil } func (coordinator *Coordinator) readReviews() ([]ReviewRecord, error) { entries, errorValue := os.ReadDir(filepath.Join(coordinator.directory(), "reviews")) if os.IsNotExist(errorValue) { return []ReviewRecord{}, nil } if errorValue != nil { return nil, errorValue } reviews := []ReviewRecord{} for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } review, errorValue := readReviewWithoutTraces(filepath.Join(coordinator.directory(), "reviews", entry.Name())) if errorValue != nil { return nil, errorValue } reviews = append(reviews, review) } return reviews, nil } func readReviewWithoutTraces(path string) (ReviewRecord, error) { document, errorValue := os.ReadFile(path) if errorValue != nil { return ReviewRecord{}, errorValue } var review ReviewRecord if errorValue := json.Unmarshal(document, &review); errorValue != nil { return ReviewRecord{}, fmt.Errorf("learning review %s is invalid: %w", filepath.Base(path), errorValue) } review.Traces = nil return review, nil }